dataverse-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Dataverse MCP Server — read-only MCP tools for querying Microsoft Dataverse."""
dataverse_mcp/_app.py ADDED
@@ -0,0 +1,22 @@
1
+ """FastMCP application instance.
2
+
3
+ This module exists to avoid circular imports between server.py and tool
4
+ modules. Tool modules import ``mcp`` from here; server.py imports ``mcp``
5
+ from here and registers tool modules.
6
+ """
7
+
8
+ from mcp.server.fastmcp import FastMCP
9
+
10
+ from dataverse_mcp.client import dataverse_lifespan
11
+
12
+ mcp = FastMCP(
13
+ "dataverse_mcp",
14
+ instructions=(
15
+ "Dataverse MCP server for interacting with Microsoft Dataverse "
16
+ "environments. Use dataverse_list_solutions to discover solutions, "
17
+ "dataverse_query_table to search records, and "
18
+ "dataverse_list_tables / dataverse_get_table_metadata for schema "
19
+ "exploration."
20
+ ),
21
+ lifespan=dataverse_lifespan,
22
+ )
@@ -0,0 +1,101 @@
1
+ """DataverseClient wrapper with authentication factory and lifecycle management."""
2
+
3
+ import logging
4
+ import os
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from dataclasses import dataclass
8
+
9
+ from azure.identity import (
10
+ AzureCliCredential,
11
+ ClientSecretCredential,
12
+ InteractiveBrowserCredential,
13
+ )
14
+ from PowerPlatform.Dataverse.client import DataverseClient
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ SUPPORTED_AUTH_TYPES = ("interactive", "client_secret", "azure_cli")
19
+
20
+
21
+ def _build_credential(auth_type: str):
22
+ """Build an Azure TokenCredential based on the configured auth type.
23
+
24
+ Args:
25
+ auth_type: One of 'interactive', 'client_secret', or 'azure_cli'.
26
+
27
+ Returns:
28
+ A TokenCredential instance for authenticating with Dataverse.
29
+
30
+ Raises:
31
+ ValueError: If the auth type is not supported or required env vars are missing.
32
+ """
33
+ if auth_type == "interactive":
34
+ logger.info("Using InteractiveBrowserCredential for authentication")
35
+ return InteractiveBrowserCredential()
36
+
37
+ if auth_type == "client_secret":
38
+ tenant_id = os.environ.get("AZURE_TENANT_ID")
39
+ client_id = os.environ.get("AZURE_CLIENT_ID")
40
+ client_secret = os.environ.get("AZURE_CLIENT_SECRET")
41
+ if not all([tenant_id, client_id, client_secret]):
42
+ raise ValueError(
43
+ "client_secret auth requires AZURE_TENANT_ID, "
44
+ "AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET environment variables"
45
+ )
46
+ logger.info("Using ClientSecretCredential for authentication")
47
+ return ClientSecretCredential(
48
+ tenant_id=tenant_id,
49
+ client_id=client_id,
50
+ client_secret=client_secret,
51
+ )
52
+
53
+ if auth_type == "azure_cli":
54
+ logger.info("Using AzureCliCredential for authentication")
55
+ return AzureCliCredential()
56
+
57
+ raise ValueError(
58
+ f"Unsupported DATAVERSE_AUTH_TYPE: '{auth_type}'. "
59
+ f"Supported values: {', '.join(SUPPORTED_AUTH_TYPES)}"
60
+ )
61
+
62
+
63
+ @dataclass
64
+ class AppContext:
65
+ """Application context holding the initialized DataverseClient."""
66
+
67
+ client: DataverseClient
68
+
69
+
70
+ @asynccontextmanager
71
+ async def dataverse_lifespan(server) -> AsyncIterator[AppContext]:
72
+ """FastMCP lifespan that initializes and cleans up the DataverseClient.
73
+
74
+ Reads configuration from environment variables:
75
+ - DATAVERSE_URL: The Dataverse organization URL (required)
76
+ - DATAVERSE_AUTH_TYPE: Authentication method (default: 'azure_cli')
77
+
78
+ Yields:
79
+ AppContext containing the initialized DataverseClient.
80
+ """
81
+ dataverse_url = os.environ.get("DATAVERSE_URL")
82
+ if not dataverse_url:
83
+ raise ValueError(
84
+ "DATAVERSE_URL environment variable is required "
85
+ "(e.g., 'https://yourorg.crm.dynamics.com')"
86
+ )
87
+
88
+ auth_type = os.environ.get("DATAVERSE_AUTH_TYPE", "azure_cli").lower().strip()
89
+ logger.info(
90
+ "Initializing DataverseClient for %s (auth: %s)", dataverse_url, auth_type
91
+ )
92
+
93
+ credential = _build_credential(auth_type)
94
+ client = DataverseClient(dataverse_url, credential)
95
+
96
+ try:
97
+ logger.info("DataverseClient initialized successfully")
98
+ yield AppContext(client=client)
99
+ finally:
100
+ logger.info("Shutting down DataverseClient")
101
+ client.close()
@@ -0,0 +1,252 @@
1
+ """Pydantic input models for all Dataverse MCP tools."""
2
+
3
+ import re
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
6
+
7
+ _GUID_PATTERN = re.compile(
8
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
9
+ )
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Solution tools
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ class ListSolutionsInput(BaseModel):
18
+ """Input for listing solutions in the Dataverse environment."""
19
+
20
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
21
+
22
+ filter: str | None = Field(
23
+ default=None,
24
+ description=(
25
+ "OData $filter expression to narrow results. Use lowercase logical "
26
+ "names (e.g., \"ismanaged eq true\", \"uniquename eq 'MyApp'\")"
27
+ ),
28
+ )
29
+ select: list[str] | None = Field(
30
+ default=None,
31
+ description=(
32
+ "Columns to return. Defaults to solutionid, uniquename, "
33
+ "friendlyname, version, ismanaged, installedon, modifiedon"
34
+ ),
35
+ )
36
+ top: int = Field(
37
+ default=50,
38
+ description="Maximum number of solutions to return",
39
+ ge=1,
40
+ le=5000,
41
+ )
42
+
43
+
44
+ class GetSolutionInput(BaseModel):
45
+ """Input for retrieving a single solution by unique name or ID."""
46
+
47
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
48
+
49
+ solution_unique_name: str | None = Field(
50
+ default=None,
51
+ description=(
52
+ "The unique name of the solution (e.g., 'MyCustomApp'). "
53
+ "Provide either this or solution_id, not both."
54
+ ),
55
+ )
56
+ solution_id: str | None = Field(
57
+ default=None,
58
+ description=(
59
+ "The GUID of the solution (e.g., 'a1b2c3d4-...'). "
60
+ "Provide either this or solution_unique_name, not both."
61
+ ),
62
+ )
63
+ select: list[str] | None = Field(
64
+ default=None,
65
+ description="Columns to return. Defaults to all standard solution columns.",
66
+ )
67
+
68
+ @field_validator("solution_id")
69
+ @classmethod
70
+ def validate_solution_guid(cls, v: str | None) -> str | None:
71
+ if v is not None and not _GUID_PATTERN.match(v):
72
+ raise ValueError(f"Invalid GUID format: '{v}'")
73
+ return v
74
+
75
+ @model_validator(mode="after")
76
+ def check_identifier_provided(self) -> "GetSolutionInput":
77
+ if not self.solution_unique_name and not self.solution_id:
78
+ raise ValueError(
79
+ "Either solution_unique_name or solution_id must be provided"
80
+ )
81
+ if self.solution_unique_name and self.solution_id:
82
+ raise ValueError(
83
+ "Provide either solution_unique_name or solution_id, not both"
84
+ )
85
+ return self
86
+
87
+
88
+ class ListSolutionComponentsInput(BaseModel):
89
+ """Input for listing components within a specific solution."""
90
+
91
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
92
+
93
+ solution_id: str = Field(
94
+ ...,
95
+ description="The GUID of the solution whose components to list",
96
+ min_length=1,
97
+ )
98
+
99
+ @field_validator("solution_id")
100
+ @classmethod
101
+ def validate_solution_guid(cls, v: str) -> str:
102
+ if not _GUID_PATTERN.match(v):
103
+ raise ValueError(f"Invalid GUID format: '{v}'")
104
+ return v
105
+
106
+ component_type: int | None = Field(
107
+ default=None,
108
+ description=(
109
+ "Filter by component type code. Common values: "
110
+ "1=Entity, 2=Attribute, 3=Relationship, 9=OptionSet, "
111
+ "10=EntityRelationship, 26=View, 29=Workflow, 60=SystemForm, "
112
+ "61=WebResource, 300=CanvasApp, 371=Connector"
113
+ ),
114
+ )
115
+ top: int = Field(
116
+ default=50,
117
+ description="Maximum number of components to return",
118
+ ge=1,
119
+ le=5000,
120
+ )
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Table query tools
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ class QueryTableInput(BaseModel):
129
+ """Input for querying records from any Dataverse table."""
130
+
131
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
132
+
133
+ table_name: str = Field(
134
+ ...,
135
+ description=(
136
+ "Logical name of the table to query (e.g., 'account', 'contact', "
137
+ "'new_customtable'). Use lowercase logical names."
138
+ ),
139
+ min_length=1,
140
+ )
141
+ select: list[str] | None = Field(
142
+ default=None,
143
+ description=(
144
+ "Columns to return. Omit to return default columns. "
145
+ "Always specify this to reduce payload size "
146
+ "(e.g., ['name', 'accountid', 'telephone1'])"
147
+ ),
148
+ )
149
+ filter: str | None = Field(
150
+ default=None,
151
+ description=(
152
+ "OData $filter expression. Use lowercase logical names. "
153
+ "Examples: \"statecode eq 0\", "
154
+ "\"name eq 'Contoso'\" , "
155
+ "\"createdon gt 2024-01-01\""
156
+ ),
157
+ )
158
+ orderby: list[str] | None = Field(
159
+ default=None,
160
+ description=(
161
+ "Sort order. Each entry is 'column_name asc' or 'column_name desc' "
162
+ "(e.g., ['name asc', 'createdon desc'])"
163
+ ),
164
+ )
165
+ top: int = Field(
166
+ default=50,
167
+ description="Maximum number of records to return",
168
+ ge=1,
169
+ le=5000,
170
+ )
171
+ expand: list[str] | None = Field(
172
+ default=None,
173
+ description=(
174
+ "Navigation properties to expand (case-sensitive!). "
175
+ "Example: ['primarycontactid']"
176
+ ),
177
+ )
178
+
179
+
180
+ class GetRecordInput(BaseModel):
181
+ """Input for retrieving a single record by its ID."""
182
+
183
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
184
+
185
+ table_name: str = Field(
186
+ ...,
187
+ description="Logical name of the table (e.g., 'account', 'contact')",
188
+ min_length=1,
189
+ )
190
+ record_id: str = Field(
191
+ ...,
192
+ description="The GUID of the record to retrieve",
193
+ min_length=1,
194
+ )
195
+
196
+ @field_validator("record_id")
197
+ @classmethod
198
+ def validate_record_guid(cls, v: str) -> str:
199
+ if not _GUID_PATTERN.match(v):
200
+ raise ValueError(f"Invalid GUID format: '{v}'")
201
+ return v
202
+
203
+ select: list[str] | None = Field(
204
+ default=None,
205
+ description=(
206
+ "Columns to return. Omit to return default columns. "
207
+ "Specify to reduce payload (e.g., ['name', 'telephone1'])"
208
+ ),
209
+ )
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Metadata tools
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ class ListTablesInput(BaseModel):
218
+ """Input for listing available tables/entities in the environment."""
219
+
220
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
221
+
222
+ filter: str | None = Field(
223
+ default=None,
224
+ description=(
225
+ "OData $filter for table metadata. "
226
+ "Examples: \"IsCustomEntity eq true\", "
227
+ "\"IsManaged eq false\""
228
+ ),
229
+ )
230
+ select: list[str] | None = Field(
231
+ default=None,
232
+ description=(
233
+ "Metadata properties to return. "
234
+ "Defaults to LogicalName, SchemaName, DisplayName, "
235
+ "IsCustomEntity, IsManaged"
236
+ ),
237
+ )
238
+
239
+
240
+ class GetTableMetadataInput(BaseModel):
241
+ """Input for retrieving detailed metadata for a specific table."""
242
+
243
+ model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
244
+
245
+ table_name: str = Field(
246
+ ...,
247
+ description=(
248
+ "Logical name of the table (e.g., 'account', 'contact', "
249
+ "'new_customtable'). Use lowercase logical names."
250
+ ),
251
+ min_length=1,
252
+ )
@@ -0,0 +1,27 @@
1
+ """FastMCP server for Dataverse MCP tools."""
2
+
3
+ import logging
4
+ import sys
5
+
6
+ # Configure logging to stderr (stdout reserved for stdio transport)
7
+ logging.basicConfig(
8
+ level=logging.INFO,
9
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
10
+ stream=sys.stderr,
11
+ )
12
+
13
+ from dataverse_mcp._app import mcp # noqa: E402
14
+
15
+ # Import tool modules to trigger @mcp.tool() registration
16
+ import dataverse_mcp.tools.solutions # noqa: E402, F401
17
+ import dataverse_mcp.tools.tables # noqa: E402, F401
18
+ import dataverse_mcp.tools.metadata # noqa: E402, F401
19
+
20
+
21
+ def main():
22
+ """Entry point for the Dataverse MCP server."""
23
+ mcp.run()
24
+
25
+
26
+ if __name__ == "__main__":
27
+ main()
@@ -0,0 +1 @@
1
+ """Dataverse MCP tool modules."""
@@ -0,0 +1,147 @@
1
+ """Table and column metadata tools for the Dataverse MCP server."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+
7
+ from mcp.server.fastmcp import Context
8
+ from PowerPlatform.Dataverse.core.errors import DataverseError, HttpError
9
+
10
+ from dataverse_mcp._app import mcp
11
+ from dataverse_mcp.client import AppContext
12
+ from dataverse_mcp.models import GetTableMetadataInput, ListTablesInput
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _DEFAULT_TABLE_SELECT = [
17
+ "LogicalName",
18
+ "SchemaName",
19
+ "DisplayName",
20
+ "EntitySetName",
21
+ "IsCustomEntity",
22
+ "IsManaged",
23
+ ]
24
+
25
+
26
+ def _get_client(ctx: Context):
27
+ """Extract the DataverseClient from the FastMCP lifespan context."""
28
+ app_ctx: AppContext = ctx.request_context.lifespan_context
29
+ return app_ctx.client
30
+
31
+
32
+ @mcp.tool(
33
+ name="dataverse_list_tables",
34
+ annotations={
35
+ "title": "List Tables",
36
+ "readOnlyHint": True,
37
+ "destructiveHint": False,
38
+ "idempotentHint": True,
39
+ "openWorldHint": True,
40
+ },
41
+ )
42
+ async def dataverse_list_tables(params: ListTablesInput, ctx: Context) -> str:
43
+ """List available tables (entities) in the Dataverse environment.
44
+
45
+ Returns table metadata including logical name, schema name, and display
46
+ name. By default returns all non-private tables. Use filter to narrow
47
+ results (e.g., "IsCustomEntity eq true" for custom tables only).
48
+
49
+ Use this tool to discover which tables exist before querying them with
50
+ dataverse_query_table or inspecting their schema with
51
+ dataverse_get_table_metadata.
52
+ """
53
+ client = _get_client(ctx)
54
+ select = params.select or _DEFAULT_TABLE_SELECT
55
+
56
+ try:
57
+
58
+ def _query():
59
+ return client.tables.list(
60
+ filter=params.filter,
61
+ select=select,
62
+ )
63
+
64
+ tables = await asyncio.to_thread(_query)
65
+ return json.dumps({
66
+ "tables": tables,
67
+ "count": len(tables),
68
+ })
69
+ except HttpError as e:
70
+ logger.error("Dataverse HTTP error: %s (status=%d)", e.message, e.status_code)
71
+ return json.dumps({
72
+ "error": True,
73
+ "message": f"Dataverse returned HTTP {e.status_code}: {e.message}",
74
+ "is_transient": e.is_transient,
75
+ })
76
+ except DataverseError as e:
77
+ logger.error("Dataverse error: %s", e.message)
78
+ return json.dumps({"error": True, "message": str(e)})
79
+ except Exception as e:
80
+ logger.exception("Unexpected error in dataverse_list_tables")
81
+ return json.dumps({
82
+ "error": True,
83
+ "message": f"Unexpected error: {type(e).__name__}: {e}",
84
+ })
85
+
86
+
87
+ @mcp.tool(
88
+ name="dataverse_get_table_metadata",
89
+ annotations={
90
+ "title": "Get Table Metadata",
91
+ "readOnlyHint": True,
92
+ "destructiveHint": False,
93
+ "idempotentHint": True,
94
+ "openWorldHint": True,
95
+ },
96
+ )
97
+ async def dataverse_get_table_metadata(
98
+ params: GetTableMetadataInput, ctx: Context
99
+ ) -> str:
100
+ """Get detailed metadata for a specific Dataverse table.
101
+
102
+ Returns the table's schema name, logical name, entity set name,
103
+ primary key attribute, and primary name attribute. Use this to
104
+ understand a table's structure before querying it with
105
+ dataverse_query_table.
106
+ """
107
+ client = _get_client(ctx)
108
+
109
+ try:
110
+
111
+ def _query():
112
+ return client.tables.get(params.table_name)
113
+
114
+ info = await asyncio.to_thread(_query)
115
+
116
+ if info is None:
117
+ return json.dumps({
118
+ "error": True,
119
+ "message": f"Table not found: '{params.table_name}'",
120
+ })
121
+
122
+ return json.dumps({
123
+ "table": {
124
+ "logical_name": info.logical_name,
125
+ "schema_name": info.schema_name,
126
+ "entity_set_name": info.entity_set_name,
127
+ "metadata_id": info.metadata_id,
128
+ "primary_id_attribute": info.primary_id_attribute,
129
+ "primary_name_attribute": info.primary_name_attribute,
130
+ },
131
+ })
132
+ except HttpError as e:
133
+ logger.error("Dataverse HTTP error: %s (status=%d)", e.message, e.status_code)
134
+ return json.dumps({
135
+ "error": True,
136
+ "message": f"Dataverse returned HTTP {e.status_code}: {e.message}",
137
+ "is_transient": e.is_transient,
138
+ })
139
+ except DataverseError as e:
140
+ logger.error("Dataverse error: %s", e.message)
141
+ return json.dumps({"error": True, "message": str(e)})
142
+ except Exception as e:
143
+ logger.exception("Unexpected error in dataverse_get_table_metadata")
144
+ return json.dumps({
145
+ "error": True,
146
+ "message": f"Unexpected error: {type(e).__name__}: {e}",
147
+ })
@@ -0,0 +1,293 @@
1
+ """Solution-related tools for the Dataverse MCP server."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+
7
+ from mcp.server.fastmcp import Context
8
+ from PowerPlatform.Dataverse.core.errors import DataverseError, HttpError
9
+
10
+ from dataverse_mcp.client import AppContext
11
+ from dataverse_mcp.models import (
12
+ GetSolutionInput,
13
+ ListSolutionComponentsInput,
14
+ ListSolutionsInput,
15
+ )
16
+ from dataverse_mcp._app import mcp
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Solution component type code → display name
21
+ # https://learn.microsoft.com/en-us/power-apps/developer/data-platform/reference/entities/solutioncomponent
22
+ COMPONENT_TYPE_NAMES: dict[int, str] = {
23
+ 1: "Entity",
24
+ 2: "Attribute",
25
+ 3: "Relationship",
26
+ 9: "Option Set",
27
+ 10: "Entity Relationship",
28
+ 20: "Security Role",
29
+ 26: "Saved Query",
30
+ 29: "Workflow",
31
+ 59: "Saved Query Visualization",
32
+ 60: "System Form",
33
+ 61: "Web Resource",
34
+ 62: "Site Map",
35
+ 63: "Connection Role",
36
+ 66: "Custom Control",
37
+ 70: "Field Security Profile",
38
+ 90: "Plugin Type",
39
+ 91: "Plugin Assembly",
40
+ 92: "SDK Message Processing Step",
41
+ 300: "Canvas App",
42
+ 371: "Connector",
43
+ 372: "Environment Variable Definition",
44
+ 373: "Environment Variable Value",
45
+ 380: "AI Project Type",
46
+ 381: "AI Project",
47
+ 382: "AI Configuration",
48
+ }
49
+
50
+ _DEFAULT_SOLUTION_SELECT = [
51
+ "solutionid",
52
+ "uniquename",
53
+ "friendlyname",
54
+ "version",
55
+ "ismanaged",
56
+ "installedon",
57
+ "modifiedon",
58
+ "description",
59
+ ]
60
+
61
+ _DEFAULT_COMPONENT_SELECT = [
62
+ "solutioncomponentid",
63
+ "componenttype",
64
+ "objectid",
65
+ "rootcomponentbehavior",
66
+ ]
67
+
68
+
69
+ def _get_client(ctx: Context):
70
+ """Extract the DataverseClient from the FastMCP lifespan context."""
71
+ app_ctx: AppContext = ctx.request_context.lifespan_context
72
+ return app_ctx.client
73
+
74
+
75
+ def _flatten_records(pages, limit: int) -> list[dict]:
76
+ """Flatten paginated Record results into a list of dicts, up to limit."""
77
+ records = []
78
+ for page in pages:
79
+ for record in page:
80
+ records.append(dict(record))
81
+ if len(records) >= limit:
82
+ return records
83
+ return records
84
+
85
+
86
+ def _enrich_component_type(record: dict) -> dict:
87
+ """Add component_type_name to a solution component record."""
88
+ comp_type = record.get("componenttype")
89
+ if comp_type is not None:
90
+ record["componenttype_name"] = COMPONENT_TYPE_NAMES.get(
91
+ comp_type, f"Unknown ({comp_type})"
92
+ )
93
+ return record
94
+
95
+
96
+ @mcp.tool(
97
+ name="dataverse_list_solutions",
98
+ annotations={
99
+ "title": "List Solutions",
100
+ "readOnlyHint": True,
101
+ "destructiveHint": False,
102
+ "idempotentHint": True,
103
+ "openWorldHint": True,
104
+ },
105
+ )
106
+ async def dataverse_list_solutions(params: ListSolutionsInput, ctx: Context) -> str:
107
+ """List solutions in the connected Dataverse environment.
108
+
109
+ Returns solutions with their unique name, friendly name, version, and
110
+ managed status. Use the optional filter parameter to narrow results
111
+ (e.g., "ismanaged eq false" for unmanaged solutions only).
112
+
113
+ Use this tool to discover which solutions exist before drilling into
114
+ specific solution details or components.
115
+ """
116
+ client = _get_client(ctx)
117
+ select = params.select or _DEFAULT_SOLUTION_SELECT
118
+ top = params.top
119
+
120
+ try:
121
+
122
+ def _query():
123
+ pages = client.records.get(
124
+ "solution",
125
+ select=select,
126
+ filter=params.filter,
127
+ top=top,
128
+ )
129
+ return _flatten_records(pages, top)
130
+
131
+ records = await asyncio.to_thread(_query)
132
+ return json.dumps({
133
+ "records": records,
134
+ "count": len(records),
135
+ "has_more": len(records) >= top,
136
+ })
137
+ except HttpError as e:
138
+ logger.error("Dataverse HTTP error: %s (status=%d)", e.message, e.status_code)
139
+ return json.dumps({
140
+ "error": True,
141
+ "message": f"Dataverse returned HTTP {e.status_code}: {e.message}",
142
+ "is_transient": e.is_transient,
143
+ })
144
+ except DataverseError as e:
145
+ logger.error("Dataverse error: %s", e.message)
146
+ return json.dumps({"error": True, "message": str(e)})
147
+ except Exception as e:
148
+ logger.exception("Unexpected error in dataverse_list_solutions")
149
+ return json.dumps({
150
+ "error": True,
151
+ "message": f"Unexpected error: {type(e).__name__}: {e}",
152
+ })
153
+
154
+
155
+ @mcp.tool(
156
+ name="dataverse_get_solution",
157
+ annotations={
158
+ "title": "Get Solution",
159
+ "readOnlyHint": True,
160
+ "destructiveHint": False,
161
+ "idempotentHint": True,
162
+ "openWorldHint": True,
163
+ },
164
+ )
165
+ async def dataverse_get_solution(params: GetSolutionInput, ctx: Context) -> str:
166
+ """Retrieve a single solution by its unique name or ID.
167
+
168
+ Provide either solution_unique_name or solution_id (not both).
169
+ Returns full solution details including version, publisher, and
170
+ managed status.
171
+
172
+ Use this after dataverse_list_solutions to get full details for
173
+ a specific solution.
174
+ """
175
+ client = _get_client(ctx)
176
+ select = params.select or _DEFAULT_SOLUTION_SELECT
177
+
178
+ try:
179
+
180
+ def _query():
181
+ if params.solution_id:
182
+ return dict(
183
+ client.records.get(
184
+ "solution",
185
+ record_id=params.solution_id,
186
+ select=select,
187
+ )
188
+ )
189
+ # Query by unique name — escape single quotes for OData safety
190
+ escaped_name = params.solution_unique_name.replace("'", "''")
191
+ odata_filter = f"uniquename eq '{escaped_name}'"
192
+ pages = client.records.get(
193
+ "solution",
194
+ select=select,
195
+ filter=odata_filter,
196
+ top=1,
197
+ )
198
+ results = _flatten_records(pages, 1)
199
+ return results[0] if results else None
200
+
201
+ record = await asyncio.to_thread(_query)
202
+
203
+ if record is None:
204
+ identifier = params.solution_unique_name or params.solution_id
205
+ return json.dumps({
206
+ "error": True,
207
+ "message": f"Solution not found: '{identifier}'",
208
+ })
209
+
210
+ return json.dumps({"record": record})
211
+ except HttpError as e:
212
+ logger.error("Dataverse HTTP error: %s (status=%d)", e.message, e.status_code)
213
+ return json.dumps({
214
+ "error": True,
215
+ "message": f"Dataverse returned HTTP {e.status_code}: {e.message}",
216
+ "is_transient": e.is_transient,
217
+ })
218
+ except DataverseError as e:
219
+ logger.error("Dataverse error: %s", e.message)
220
+ return json.dumps({"error": True, "message": str(e)})
221
+ except Exception as e:
222
+ logger.exception("Unexpected error in dataverse_get_solution")
223
+ return json.dumps({
224
+ "error": True,
225
+ "message": f"Unexpected error: {type(e).__name__}: {e}",
226
+ })
227
+
228
+
229
+ @mcp.tool(
230
+ name="dataverse_list_solution_components",
231
+ annotations={
232
+ "title": "List Solution Components",
233
+ "readOnlyHint": True,
234
+ "destructiveHint": False,
235
+ "idempotentHint": True,
236
+ "openWorldHint": True,
237
+ },
238
+ )
239
+ async def dataverse_list_solution_components(
240
+ params: ListSolutionComponentsInput, ctx: Context
241
+ ) -> str:
242
+ """List components within a specific solution.
243
+
244
+ Returns the components (entities, web resources, workflows, etc.)
245
+ that belong to the specified solution. Each component includes both
246
+ the integer type code and a human-readable type name.
247
+
248
+ Use component_type to filter by a specific type (e.g., 1 for Entity,
249
+ 61 for Web Resource, 300 for Canvas App). Use dataverse_get_solution
250
+ first to find the solution_id.
251
+ """
252
+ client = _get_client(ctx)
253
+ top = params.top
254
+
255
+ # solution_id is already GUID-validated by Pydantic
256
+ odata_filter = f"_solutionid_value eq '{params.solution_id}'"
257
+ if params.component_type is not None:
258
+ odata_filter += f" and componenttype eq {params.component_type}"
259
+
260
+ try:
261
+
262
+ def _query():
263
+ pages = client.records.get(
264
+ "solutioncomponent",
265
+ select=_DEFAULT_COMPONENT_SELECT,
266
+ filter=odata_filter,
267
+ top=top,
268
+ )
269
+ records = _flatten_records(pages, top)
270
+ return [_enrich_component_type(r) for r in records]
271
+
272
+ records = await asyncio.to_thread(_query)
273
+ return json.dumps({
274
+ "records": records,
275
+ "count": len(records),
276
+ "has_more": len(records) >= top,
277
+ })
278
+ except HttpError as e:
279
+ logger.error("Dataverse HTTP error: %s (status=%d)", e.message, e.status_code)
280
+ return json.dumps({
281
+ "error": True,
282
+ "message": f"Dataverse returned HTTP {e.status_code}: {e.message}",
283
+ "is_transient": e.is_transient,
284
+ })
285
+ except DataverseError as e:
286
+ logger.error("Dataverse error: %s", e.message)
287
+ return json.dumps({"error": True, "message": str(e)})
288
+ except Exception as e:
289
+ logger.exception("Unexpected error in dataverse_list_solution_components")
290
+ return json.dumps({
291
+ "error": True,
292
+ "message": f"Unexpected error: {type(e).__name__}: {e}",
293
+ })
@@ -0,0 +1,141 @@
1
+ """Table query tools for the Dataverse MCP server."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+
7
+ from mcp.server.fastmcp import Context
8
+ from PowerPlatform.Dataverse.core.errors import DataverseError, HttpError
9
+
10
+ from dataverse_mcp._app import mcp
11
+ from dataverse_mcp.client import AppContext
12
+ from dataverse_mcp.models import GetRecordInput, QueryTableInput
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _get_client(ctx: Context):
18
+ """Extract the DataverseClient from the FastMCP lifespan context."""
19
+ app_ctx: AppContext = ctx.request_context.lifespan_context
20
+ return app_ctx.client
21
+
22
+
23
+ def _flatten_records(pages, limit: int) -> list[dict]:
24
+ """Flatten paginated Record results into a list of dicts, up to limit."""
25
+ records = []
26
+ for page in pages:
27
+ for record in page:
28
+ records.append(dict(record))
29
+ if len(records) >= limit:
30
+ return records
31
+ return records
32
+
33
+
34
+ @mcp.tool(
35
+ name="dataverse_query_table",
36
+ annotations={
37
+ "title": "Query Table",
38
+ "readOnlyHint": True,
39
+ "destructiveHint": False,
40
+ "idempotentHint": True,
41
+ "openWorldHint": True,
42
+ },
43
+ )
44
+ async def dataverse_query_table(params: QueryTableInput, ctx: Context) -> str:
45
+ """Query records from any Dataverse table.
46
+
47
+ Returns matching records from the specified table. Supports OData-style
48
+ filtering, column selection, sorting, and navigation property expansion.
49
+
50
+ Always specify select to limit returned columns and reduce payload size.
51
+ Default top is 50 to prevent overwhelming context — increase if needed.
52
+
53
+ Use dataverse_list_tables or dataverse_get_table_metadata first to
54
+ discover available tables and their column names.
55
+ """
56
+ client = _get_client(ctx)
57
+ top = params.top
58
+
59
+ try:
60
+
61
+ def _query():
62
+ pages = client.records.get(
63
+ params.table_name,
64
+ select=params.select,
65
+ filter=params.filter,
66
+ orderby=params.orderby,
67
+ top=top,
68
+ expand=params.expand,
69
+ )
70
+ return _flatten_records(pages, top)
71
+
72
+ records = await asyncio.to_thread(_query)
73
+ return json.dumps({
74
+ "records": records,
75
+ "count": len(records),
76
+ "has_more": len(records) >= top,
77
+ })
78
+ except HttpError as e:
79
+ logger.error("Dataverse HTTP error: %s (status=%d)", e.message, e.status_code)
80
+ return json.dumps({
81
+ "error": True,
82
+ "message": f"Dataverse returned HTTP {e.status_code}: {e.message}",
83
+ "is_transient": e.is_transient,
84
+ })
85
+ except DataverseError as e:
86
+ logger.error("Dataverse error: %s", e.message)
87
+ return json.dumps({"error": True, "message": str(e)})
88
+ except Exception as e:
89
+ logger.exception("Unexpected error in dataverse_query_table")
90
+ return json.dumps({
91
+ "error": True,
92
+ "message": f"Unexpected error: {type(e).__name__}: {e}",
93
+ })
94
+
95
+
96
+ @mcp.tool(
97
+ name="dataverse_get_record",
98
+ annotations={
99
+ "title": "Get Record",
100
+ "readOnlyHint": True,
101
+ "destructiveHint": False,
102
+ "idempotentHint": True,
103
+ "openWorldHint": True,
104
+ },
105
+ )
106
+ async def dataverse_get_record(params: GetRecordInput, ctx: Context) -> str:
107
+ """Retrieve a single record by its ID from any Dataverse table.
108
+
109
+ Returns the full record (or selected columns) for the given table
110
+ and record GUID. Use dataverse_query_table first to find record IDs.
111
+ """
112
+ client = _get_client(ctx)
113
+
114
+ try:
115
+
116
+ def _query():
117
+ record = client.records.get(
118
+ params.table_name,
119
+ record_id=params.record_id,
120
+ select=params.select,
121
+ )
122
+ return dict(record)
123
+
124
+ record = await asyncio.to_thread(_query)
125
+ return json.dumps({"record": record})
126
+ except HttpError as e:
127
+ logger.error("Dataverse HTTP error: %s (status=%d)", e.message, e.status_code)
128
+ return json.dumps({
129
+ "error": True,
130
+ "message": f"Dataverse returned HTTP {e.status_code}: {e.message}",
131
+ "is_transient": e.is_transient,
132
+ })
133
+ except DataverseError as e:
134
+ logger.error("Dataverse error: %s", e.message)
135
+ return json.dumps({"error": True, "message": str(e)})
136
+ except Exception as e:
137
+ logger.exception("Unexpected error in dataverse_get_record")
138
+ return json.dumps({
139
+ "error": True,
140
+ "message": f"Unexpected error: {type(e).__name__}: {e}",
141
+ })
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: dataverse-mcp
3
+ Version: 0.1.0
4
+ Summary: A read-only MCP server for querying Microsoft Dataverse environments during development
5
+ Author: Ryan James
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ryanmichaeljames/dataverse-mcp
8
+ Project-URL: Repository, https://github.com/ryanmichaeljames/dataverse-mcp
9
+ Project-URL: Issues, https://github.com/ryanmichaeljames/dataverse-mcp/issues
10
+ Project-URL: Changelog, https://github.com/ryanmichaeljames/dataverse-mcp/blob/main/CHANGELOG.md
11
+ Keywords: mcp,model-context-protocol,dataverse,power-platform,copilot,llm
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: azure-identity>=1.25.3
24
+ Requires-Dist: httpx<1.0,>=0.20.0
25
+ Requires-Dist: mcp[cli]>=1.27.0
26
+ Requires-Dist: powerplatform-dataverse-client>=0.1.0b7
27
+ Dynamic: license-file
28
+
29
+ # Dataverse MCP Server
30
+
31
+ An [MCP](https://modelcontextprotocol.io/) server for interacting with Microsoft Dataverse environments. Built with [FastMCP](https://github.com/modelcontextprotocol/python-sdk) and the official [PowerPlatform-Dataverse-Client](https://pypi.org/project/PowerPlatform-Dataverse-Client/) Python SDK.
32
+
33
+ ## Features
34
+
35
+ - **Solution inspection** — list solutions, get solution details, browse solution components
36
+ - **Table querying** — flexible OData-style queries against any Dataverse table
37
+ - **Schema exploration** — list tables, inspect table metadata (primary key, name attribute)
38
+ - **Agent-friendly** — rich tool descriptions designed for AI agent discoverability
39
+ - **Secure** — Pydantic v2 input validation, GUID format enforcement, OData injection prevention
40
+
41
+ ## Prerequisites
42
+
43
+ - [uv](https://docs.astral.sh/uv/) — install from [docs.astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/)
44
+ - Access to a Microsoft Dataverse environment
45
+ - Azure CLI (`az login`) or a registered app for authentication
46
+
47
+ ## Installation
48
+
49
+ No install required — run directly from PyPI using `uvx`:
50
+
51
+ ```bash
52
+ uvx dataverse-mcp
53
+ ```
54
+
55
+ `uvx` downloads and runs the package in an isolated environment. No cloning, no virtual env setup.
56
+
57
+ ## Configuration
58
+
59
+ Copy the example environment file and fill in your values:
60
+
61
+ ```bash
62
+ cp .env.example .env
63
+ ```
64
+
65
+ | Variable | Required | Default | Description |
66
+ |----------|----------|---------|-------------|
67
+ | `DATAVERSE_URL` | Yes | — | Your Dataverse org URL (e.g., `https://yourorg.crm.dynamics.com`) |
68
+ | `DATAVERSE_AUTH_TYPE` | No | `azure_cli` | Auth method: `interactive`, `client_secret`, or `azure_cli` |
69
+ | `AZURE_TENANT_ID` | For `client_secret` | — | Azure AD tenant ID |
70
+ | `AZURE_CLIENT_ID` | For `client_secret` | — | App registration client ID |
71
+ | `AZURE_CLIENT_SECRET` | For `client_secret` | — | App registration client secret |
72
+
73
+ ### Authentication Methods
74
+
75
+ - **`azure_cli`** (default) — Uses your existing `az login` session. Best for local development.
76
+ - **`interactive`** — Opens a browser window for interactive sign-in.
77
+ - **`client_secret`** — Uses a service principal. Requires `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, and `AZURE_CLIENT_SECRET`.
78
+
79
+ ## Usage
80
+
81
+ This server communicates over stdio and works with any MCP-compatible client.
82
+
83
+ ### VS Code
84
+
85
+ Add the server to your VS Code MCP configuration (`.vscode/mcp.json`):
86
+
87
+ ```json
88
+ {
89
+ "servers": {
90
+ "dataverse-mcp": {
91
+ "type": "stdio",
92
+ "command": "uvx",
93
+ "args": ["dataverse-mcp"],
94
+ "env": {
95
+ "DATAVERSE_URL": "https://yourorg.crm.dynamics.com",
96
+ "DATAVERSE_AUTH_TYPE": "azure_cli"
97
+ }
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ To connect to multiple environments, add one entry per environment with a unique key:
104
+
105
+ ```json
106
+ {
107
+ "servers": {
108
+ "dataverse-mcp-dev": {
109
+ "type": "stdio",
110
+ "command": "uvx",
111
+ "args": ["dataverse-mcp"],
112
+ "env": {
113
+ "DATAVERSE_URL": "https://yourorg-dev.crm.dynamics.com",
114
+ "DATAVERSE_AUTH_TYPE": "azure_cli"
115
+ }
116
+ },
117
+ "dataverse-mcp-test": {
118
+ "type": "stdio",
119
+ "command": "uvx",
120
+ "args": ["dataverse-mcp"],
121
+ "env": {
122
+ "DATAVERSE_URL": "https://yourorg-test.crm.dynamics.com",
123
+ "DATAVERSE_AUTH_TYPE": "azure_cli"
124
+ }
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ ## Tools
131
+
132
+ | Tool | Description |
133
+ |------|-------------|
134
+ | `dataverse_list_solutions` | List solutions with optional OData filter, select, and top |
135
+ | `dataverse_get_solution` | Get a single solution by unique name or GUID |
136
+ | `dataverse_list_solution_components` | List components in a solution with optional type filter |
137
+ | `dataverse_query_table` | Query records from any table with filter, select, orderby, expand, top |
138
+ | `dataverse_get_record` | Get a single record by table name and GUID |
139
+ | `dataverse_list_tables` | List available tables/entities with optional filter |
140
+ | `dataverse_get_table_metadata` | Get schema details for a specific table |
141
+
142
+ ## Project Structure
143
+
144
+ ```
145
+ src/dataverse_mcp/
146
+ ├── __init__.py # Package init
147
+ ├── _app.py # FastMCP instance (avoids circular imports)
148
+ ├── server.py # Entry point, logging setup, tool registration
149
+ ├── client.py # DataverseClient wrapper (auth, lifecycle)
150
+ ├── models.py # Pydantic v2 input models for all tools
151
+ └── tools/
152
+ ├── __init__.py # Tools package init
153
+ ├── solutions.py # Solution query tools
154
+ ├── tables.py # Table record query tools
155
+ └── metadata.py # Table/column metadata tools
156
+ ```
157
+
158
+ ## Development
159
+
160
+ ```bash
161
+ # Clone the repo
162
+ git clone https://github.com/ryanmichaeljames/dataverse-mcp.git
163
+ cd dataverse-mcp
164
+
165
+ # Install dependencies
166
+ uv sync
167
+
168
+ # Run the MCP inspector for testing
169
+ uv run mcp dev src/dataverse_mcp/server.py
170
+
171
+ # Compile check all modules
172
+ uv run python -m py_compile src/dataverse_mcp/server.py
173
+ ```
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,15 @@
1
+ dataverse_mcp/__init__.py,sha256=8VxXY19tOjLshtSnDs1sv4saYA-SKYq9rl7r1rtLK94,85
2
+ dataverse_mcp/_app.py,sha256=UkBt4dNLU1ERWVGLTl-Iz6UyTcJLjymdKGlDJUDUyyg,718
3
+ dataverse_mcp/client.py,sha256=xHcKH4yn20kt4Qnt6-T5C5gfcYq3BDGI-QZHCXV5ZqY,3334
4
+ dataverse_mcp/models.py,sha256=wsdJjvKGIq229aRZN-vMM43vE5ZrCQS_iFdWQmSGBOE,7777
5
+ dataverse_mcp/server.py,sha256=2Unth7sSlOEkanv3eSGmOlmIYgRQ1qVQAkpd7p6A3WM,675
6
+ dataverse_mcp/tools/__init__.py,sha256=MIYqOIDPe12iOP2h_RFgTHiuuaEE3bQW_sdGDCYmBuE,34
7
+ dataverse_mcp/tools/metadata.py,sha256=W7_kuO4hXvLFroT6bGzCINXUxU205R3Hd7zbVO9mDZs,4699
8
+ dataverse_mcp/tools/solutions.py,sha256=4ElxrWl8kU4a4d9WE11R2hwB7l1f0d_0c5kEs9FPFiQ,9347
9
+ dataverse_mcp/tools/tables.py,sha256=ijvNs-qRl5j9l7g5b1LAMRyA_N3ikKPe7wkEq3vnfS4,4586
10
+ dataverse_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=hpGGbtLk_YVW3uWKzc3Fb2_EZvEbkgcNDUD_RSa4Wsw,1067
11
+ dataverse_mcp-0.1.0.dist-info/METADATA,sha256=ni4B6cJ55bKnjjacFafClzAR37W3st6TqqfGHVtmTxA,6243
12
+ dataverse_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ dataverse_mcp-0.1.0.dist-info/entry_points.txt,sha256=rV1Uph_G75ODMtkGBq86B-msdjASIrrUUWVlxnEFllE,60
14
+ dataverse_mcp-0.1.0.dist-info/top_level.txt,sha256=fxIeN_8EzsfcxzaCvJGobSsEWAv9N9FXCxGrVp_xuOU,14
15
+ dataverse_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dataverse-mcp = dataverse_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan James
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ dataverse_mcp