dtlpymcp 0.1.3__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.
dtlpymcp/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ import os
2
+ SOURCES_FILEPATH = os.path.join(os.path.dirname(__file__), "default_sources.json")
3
+
4
+ from .utils.dtlpy_context import DataloopContext, MCPSource
5
+
6
+ __version__ = "0.1.3"
7
+
dtlpymcp/__main__.py ADDED
@@ -0,0 +1,34 @@
1
+ """
2
+ CLI entry point for dtlpymcp.
3
+ Reads from STDIN and writes to STDOUT.
4
+ """
5
+ import sys
6
+ import argparse
7
+ from dtlpymcp.proxy import main as proxy_main
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(
11
+ description="Dataloop MCP Proxy Server CLI"
12
+ )
13
+ subparsers = parser.add_subparsers(dest="command", required=False)
14
+
15
+ # 'start' subcommand
16
+ start_parser = subparsers.add_parser("start", help="Start the MCP proxy server (STDIO mode)")
17
+ start_parser.add_argument(
18
+ "--sources-file",
19
+ "-s",
20
+ type=str,
21
+ default=None,
22
+ help="Path to a JSON file with MCP sources to load"
23
+ )
24
+
25
+ args = parser.parse_args()
26
+
27
+ if args.command == "start":
28
+ sys.exit(proxy_main(sources_file=args.sources_file))
29
+ else:
30
+ parser.print_help()
31
+ return 1
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,20 @@
1
+ [
2
+ {
3
+ "dpk_name": "dataloop-mcp-donna",
4
+ "app_url": null,
5
+ "server_url": null,
6
+ "app_jwt": null
7
+ },
8
+ {
9
+ "dpk_name": "dataloop-mcp-apis",
10
+ "app_url": null,
11
+ "server_url": null,
12
+ "app_jwt": null
13
+ },
14
+ {
15
+ "dpk_name": "dataloop-mcp-debug",
16
+ "app_url": null,
17
+ "server_url": null,
18
+ "app_jwt": null
19
+ }
20
+ ]
dtlpymcp/proxy.py ADDED
@@ -0,0 +1,94 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+ from mcp.server.fastmcp import FastMCP, Context
3
+ from typing import Any, Optional
4
+ from datetime import datetime
5
+ import traceback
6
+ import logging
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from . import SOURCES_FILEPATH
12
+ from .utils.dtlpy_context import DataloopContext, MCPSource
13
+
14
+ # Setup logging to both console and file with timestamp
15
+ log_dir = Path.home() / ".dataloop" / "mcplogs"
16
+ log_dir.mkdir(parents=True, exist_ok=True)
17
+ log_file = log_dir / f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.log"
18
+
19
+ # Remove any existing handlers
20
+ for handler in logging.root.handlers[:]:
21
+ logging.root.removeHandler(handler)
22
+
23
+ # File handler with timestamp
24
+ file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
25
+ file_handler.setFormatter(
26
+ logging.Formatter(fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
27
+ )
28
+
29
+ # Console handler (default format)
30
+ console_handler = logging.StreamHandler()
31
+ console_handler.setFormatter(logging.Formatter(fmt="[%(levelname)s] %(name)s: %(message)s"))
32
+
33
+ logging.basicConfig(level=logging.INFO, handlers=[file_handler, console_handler])
34
+ logger = logging.getLogger("[DATALOOP-MCP]")
35
+
36
+
37
+ class ServerSettings(BaseSettings):
38
+ """Settings for the Dataloop MCP server."""
39
+
40
+ model_config = SettingsConfigDict(env_prefix="MCP_DATALOOP_")
41
+
42
+ def __init__(self, **data):
43
+ super().__init__(**data)
44
+
45
+
46
+ def create_dataloop_mcp_server(settings: ServerSettings, sources_file: str) -> FastMCP:
47
+ """Create a FastMCP server for Dataloop with Bearer token authentication."""
48
+ app = FastMCP(
49
+ name="Dataloop MCP Server",
50
+ instructions="A multi-tenant MCP server for Dataloop with authentication",
51
+ stateless_http=True,
52
+ debug=True,
53
+ )
54
+ dl_context = DataloopContext(token=os.environ.get('DATALOOP_API_KEY'),
55
+ sources_file=sources_file)
56
+
57
+ @app.tool(description="Test tool for health checks")
58
+ async def test(ctx: Context, ping: Any = None) -> dict[str, Any]:
59
+ """Health check tool. Returns status ok and echoes ping if provided."""
60
+ result = {"status": "ok"}
61
+ if ping is not None:
62
+ result["ping"] = ping
63
+ return result
64
+
65
+ for source in dl_context.mcp_sources:
66
+ for tool in source.tools:
67
+ app._tool_manager._tools[tool.name] = tool
68
+ return app
69
+
70
+
71
+ def main(sources_file: Optional[str] = None) -> int:
72
+ logger.info("Starting Dataloop MCP server in stdio mode")
73
+ try:
74
+ settings = ServerSettings()
75
+ logger.info("Successfully configured Dataloop MCP server")
76
+ except Exception as e:
77
+ logger.error(f"Unexpected error during startup:\n{e}")
78
+ return 1
79
+ try:
80
+ if sources_file is None:
81
+ sources_file = SOURCES_FILEPATH
82
+ logger.info(f"Using sources file: {sources_file}")
83
+ mcp_server = create_dataloop_mcp_server(settings, sources_file)
84
+ logger.info("Starting Dataloop MCP server in stdio mode")
85
+ logger.info("Users should provide their API key in the Authorization header as a Bearer token")
86
+ mcp_server.run(transport="stdio")
87
+ return 0
88
+ except Exception:
89
+ logger.error(f"Failed to start MCP server: {traceback.format_exc()}")
90
+ return 1
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
@@ -0,0 +1,233 @@
1
+ from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
2
+ from mcp.client.streamable_http import streamablehttp_client
3
+ from typing import Any, List, Tuple, Callable, Optional
4
+ from mcp.server.fastmcp.tools.base import Tool
5
+ from mcp.server.fastmcp import FastMCP
6
+ from pydantic import BaseModel, Field
7
+ from pydantic import create_model
8
+ from datetime import timedelta
9
+ from mcp import ClientSession
10
+ import dtlpy as dl
11
+ import traceback
12
+ import requests
13
+ import logging
14
+ import asyncio
15
+ import time
16
+ import jwt
17
+ import json
18
+ from .. import SOURCES_FILEPATH
19
+
20
+ # Utility to run async code from sync or async context
21
+ # If called from a running event loop, returns a Task (caller must handle it)
22
+ # If called from sync, blocks and returns result
23
+
24
+ def run_async(coro):
25
+ try:
26
+ loop = asyncio.get_running_loop()
27
+ except RuntimeError:
28
+ # No event loop running
29
+ return asyncio.run(coro)
30
+ else:
31
+ # Already running event loop
32
+ return loop.create_task(coro)
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class MCPSource(BaseModel):
38
+ dpk_name: Optional[str] = None
39
+ app_url: Optional[str] = None
40
+ server_url: Optional[str] = None
41
+ app_jwt: Optional[str] = None
42
+ tools: Optional[List[Tool]] = []
43
+
44
+
45
+ class DataloopContext:
46
+ """
47
+ DataloopContext manages authentication, tool discovery, and proxy registration for Dataloop MCP servers.
48
+ Handles JWTs, server URLs, and dynamic tool registration for multi-tenant environments.
49
+ """
50
+
51
+ def __init__(self, token: str = None, sources_file: str = None):
52
+ self._token = token
53
+ self.mcp_sources: List[MCPSource] = []
54
+ logger.info("DataloopContext initialized.")
55
+ if sources_file is None:
56
+ sources_file = SOURCES_FILEPATH
57
+ self.sources_file = sources_file
58
+ self.initialized = False
59
+
60
+
61
+ async def initialize(self, force: bool = False):
62
+ if not self.initialized or force:
63
+ await self.register_sources(self.sources_file)
64
+ self.initialized = True
65
+
66
+ async def register_sources(self, sources_file: str):
67
+ with open(sources_file, "r") as f:
68
+ data = json.load(f)
69
+ logger.info(f"Loading MCP sources from {sources_file}")
70
+ for entry in data:
71
+ if not isinstance(entry, dict):
72
+ raise ValueError(f"Invalid source entry: {entry}")
73
+ logger.info(f"Adding MCP source: {entry.get('dpk_name')}, url: {entry.get('server_url')}")
74
+ await self.add_mcp_source(MCPSource(**entry))
75
+
76
+ async def add_mcp_source(self, mcp_source: MCPSource):
77
+ self.mcp_sources.append(mcp_source)
78
+ if mcp_source.server_url is None:
79
+ self.load_app_info(mcp_source)
80
+ result = await self.list_source_tools(mcp_source)
81
+ if result is None:
82
+ raise ValueError(f"Failed to discover tools for source {mcp_source.dpk_name}")
83
+ server_name, tools, call_fn = result
84
+ for tool in tools.tools:
85
+ tool_name = tool.name
86
+ ns_tool_name = f"{server_name}.{tool_name}"
87
+ description = tool.description
88
+ input_schema = tool.inputSchema
89
+
90
+ def build_handler(tool_name):
91
+ async def inner(**kwargs):
92
+ fn = call_fn(tool_name, kwargs)
93
+ return await fn()
94
+
95
+ return inner
96
+
97
+ dynamic_pydantic_model_params = self.build_pydantic_fields_from_schema(input_schema)
98
+ arguments_model = create_model(
99
+ f"{tool_name}Arguments", **dynamic_pydantic_model_params, __base__=ArgModelBase
100
+ )
101
+ resp = FuncMetadata(arg_model=arguments_model)
102
+ t = Tool(
103
+ fn=build_handler(tool_name),
104
+ name=ns_tool_name,
105
+ description=description,
106
+ parameters=input_schema,
107
+ fn_metadata=resp,
108
+ is_async=True,
109
+ context_kwarg="ctx",
110
+ annotations=None,
111
+ )
112
+ mcp_source.tools.append(t)
113
+ tool_str = ", ".join([tool.name for tool in mcp_source.tools])
114
+ logger.info(f"Added MCP source: {mcp_source.dpk_name}, Available tools: {tool_str}")
115
+
116
+ @property
117
+ def token(self) -> str:
118
+ return self._token
119
+
120
+ @token.setter
121
+ def token(self, token: str):
122
+ self._token = token
123
+
124
+ def load_app_info(self, source: MCPSource) -> None:
125
+ """
126
+ Get the source URL and app JWT for a given DPK name using Dataloop SDK.
127
+ """
128
+ try:
129
+ dl.client_api.token = self.token
130
+ dpk = dl.dpks.get(dpk_name=source.dpk_name)
131
+ apps_filters = dl.Filters(field='dpkName', values=dpk.name, resource='apps')
132
+ app = dl.apps.list(filters=apps_filters).items[0]
133
+ logger.info(f"App: {app.name}")
134
+ source.app_url = next(iter(app.routes.values()))
135
+ session = requests.Session()
136
+ response = session.get(source.app_url, headers=dl.client_api.auth)
137
+ logger.info(f"App route URL: {response.url}")
138
+ source.server_url = response.url
139
+ source.app_jwt = session.cookies.get("JWT-APP")
140
+ except Exception:
141
+ logger.error(f"Failed getting app info: {traceback.format_exc()}")
142
+ raise ValueError(f"Failed getting app info: {traceback.format_exc()}")
143
+
144
+ @staticmethod
145
+ def is_expired(app_jwt: str) -> bool:
146
+ """
147
+ Check if the APP_JWT is expired.
148
+ """
149
+ try:
150
+ decoded = jwt.decode(app_jwt, options={"verify_signature": False})
151
+ if decoded.get("exp") < time.time():
152
+ return True
153
+ return False
154
+ except jwt.ExpiredSignatureError:
155
+ return True
156
+ except Exception as e:
157
+ logger.error(f"Error decoding JWT: {e}")
158
+ return True
159
+
160
+ def get_app_jwt(self, source: MCPSource, token: str) -> str:
161
+ """
162
+ Get the APP_JWT from the request headers or refresh if expired.
163
+ """
164
+ if source.app_jwt is None or self.is_expired(source.app_jwt):
165
+ try:
166
+ session = requests.Session()
167
+ response = session.get(source.app_url, headers={'authorization': 'Bearer ' + token})
168
+ source.app_jwt = session.cookies.get("JWT-APP")
169
+ except Exception:
170
+ logger.error(f"Failed getting app JWT from cookies\n{traceback.format_exc()}")
171
+ raise Exception(f"Failed getting app JWT from cookies\n{traceback.format_exc()}") from None
172
+ if not source.app_jwt:
173
+ logger.error(
174
+ "APP_JWT is missing. Please set the APP_JWT environment variable or ensure authentication is working."
175
+ )
176
+ raise ValueError(
177
+ "APP_JWT is missing. Please set the APP_JWT environment variable or ensure authentication is working."
178
+ )
179
+ return source.app_jwt
180
+
181
+ @staticmethod
182
+ def user_info(token: str) -> dict:
183
+ """
184
+ Decode a JWT token and return user info.
185
+ """
186
+ decoded = jwt.decode(token, options={"verify_signature": False})
187
+ return decoded
188
+
189
+ async def list_source_tools(self, source: MCPSource) -> Tuple[str, List[dict], Callable]:
190
+ """
191
+ Discover tools for a given source and return (server_name, list_of_tools, call_fn).
192
+ """
193
+ if source.server_url is None:
194
+ logger.error("DataloopContext required for DPK servers")
195
+ raise ValueError("DataloopContext required for DPK servers")
196
+ headers = {"Cookie": f"JWT-APP={source.app_jwt}",
197
+ "x-dl-info": f"{self.token}"}
198
+ async with streamablehttp_client(source.server_url, headers=headers) as (read, write, _):
199
+ async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as session:
200
+ await session.initialize()
201
+ tools = await session.list_tools()
202
+
203
+ def call_fn(tool_name, kwargs):
204
+ async def inner():
205
+ async with streamablehttp_client(source.server_url, headers=headers) as (read, write, _):
206
+ async with ClientSession(
207
+ read, write, read_timeout_seconds=timedelta(seconds=60)
208
+ ) as session:
209
+ await session.initialize()
210
+ return await session.call_tool(tool_name, kwargs)
211
+
212
+ return inner
213
+
214
+ logger.info(f"Discovered {len(tools.tools)} tools for source {source.dpk_name}")
215
+ return (source.dpk_name, tools, call_fn)
216
+
217
+ def openapi_type_to_python(self, type_str):
218
+ return {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}.get(
219
+ type_str, str
220
+ )
221
+
222
+ def build_pydantic_fields_from_schema(self, input_schema):
223
+ required = set(input_schema.get("required", []))
224
+ properties = input_schema.get("properties", {})
225
+ fields = {}
226
+ for name, prop in properties.items():
227
+ py_type = self.openapi_type_to_python(prop.get("type", "string"))
228
+ if name in required:
229
+ fields[name] = (py_type, Field(...))
230
+ else:
231
+ default = prop.get("default", None)
232
+ fields[name] = (py_type, Field(default=default))
233
+ return fields
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: dtlpymcp
3
+ Version: 0.1.3
4
+ Summary: STDIO MCP proxy server for Dataloop platform.
5
+ Author-email: Your Name <your.email@example.com>
6
+ Classifier: Programming Language :: Python :: 3.10
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: dtlpy
12
+ Requires-Dist: pydantic-settings
13
+ Requires-Dist: mcp
14
+ Requires-Dist: requests
15
+ Requires-Dist: pyjwt
16
+ Requires-Dist: makefun
17
+
18
+ # Dataloop MCP Proxy Server
19
+
20
+ This is the main proxy for the Dataloop Micro MCPs, installable as a Python package.
21
+
22
+ ## Installation
23
+
24
+ ```shell
25
+ pip install git+<repository-url>
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ You can run the proxy server via CLI:
31
+
32
+ ```shell
33
+ dtlpymcp start
34
+ ```
35
+
36
+ Or using Python module syntax:
37
+
38
+ ```shell
39
+ python -m dtlpymcp start
40
+ ```
41
+
42
+ ## Local Development
43
+
44
+ - Requires Python 3.10+
45
+ - Install dependencies with `pip install -e .`
46
+ - Run tests with `pytest`
47
+
48
+ ## Cursor MCP Integration
49
+
50
+ To add this MCP to Cursor, add the following to your configuration:
51
+
52
+ ### Docker Example
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "dataloop-ai-mcp": {
57
+ "command": "docker run -i --rm -e DATALOOP_API_KEY docker.io/dataloopai/mcp:latest",
58
+ "env": {
59
+ "DATALOOP_API_KEY": "API KEY"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ### Local CLI Example
67
+ ```json
68
+ {
69
+ "mcpServers": {
70
+ "dataloop-ai-mcp": {
71
+ "command": "dtlpymcp start",
72
+ "env": {
73
+ "DATALOOP_API_KEY": "API KEY"
74
+ }
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ Replace `API KEY` with your actual Dataloop API key.
@@ -0,0 +1,10 @@
1
+ dtlpymcp/__init__.py,sha256=ISimynz4Nggmf-PH4y1xR2gddnazpe77QNLxSAWM9IM,185
2
+ dtlpymcp/__main__.py,sha256=1lo4qoZAoUHl9rkt1YGB2kCpKg5cIrTSBSrznxyk6F4,884
3
+ dtlpymcp/default_sources.json,sha256=Es3XZcdMpe6o5FyOoqW9FG_oUjC5gkNbBC6N9eBqpQs,418
4
+ dtlpymcp/proxy.py,sha256=_7BGShm-V5LZCe24vbFsysF-Z_Yc70Uuxilot3P5uP4,3447
5
+ dtlpymcp/utils/dtlpy_context.py,sha256=A8TCGjcvRvUc38etrIY-IZlU-JDOPwVUsUJKweKJ1nk,9670
6
+ dtlpymcp-0.1.3.dist-info/METADATA,sha256=zWm9OaXKvM9oIIphSuPNDgtWbylrLkdFlPMpFag3D28,1617
7
+ dtlpymcp-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ dtlpymcp-0.1.3.dist-info/entry_points.txt,sha256=6hRVZNTjQevj7erwt9dAOURtPVrSrYu6uHXhAlhTaXQ,52
9
+ dtlpymcp-0.1.3.dist-info/top_level.txt,sha256=z85v20pIEnY3cBaWgwhU3EZS4WAZRywejhIutwd-iHk,9
10
+ dtlpymcp-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dtlpymcp = dtlpymcp.__main__:main
@@ -0,0 +1 @@
1
+ dtlpymcp