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.
- dataverse_mcp/__init__.py +1 -0
- dataverse_mcp/_app.py +22 -0
- dataverse_mcp/client.py +101 -0
- dataverse_mcp/models.py +252 -0
- dataverse_mcp/server.py +27 -0
- dataverse_mcp/tools/__init__.py +1 -0
- dataverse_mcp/tools/metadata.py +147 -0
- dataverse_mcp/tools/solutions.py +293 -0
- dataverse_mcp/tools/tables.py +141 -0
- dataverse_mcp-0.1.0.dist-info/METADATA +177 -0
- dataverse_mcp-0.1.0.dist-info/RECORD +15 -0
- dataverse_mcp-0.1.0.dist-info/WHEEL +5 -0
- dataverse_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- dataverse_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- dataverse_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|
dataverse_mcp/client.py
ADDED
|
@@ -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()
|
dataverse_mcp/models.py
ADDED
|
@@ -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
|
+
)
|
dataverse_mcp/server.py
ADDED
|
@@ -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,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
|