dtlpymcp 0.1.7__tar.gz
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-0.1.7/MANIFEST.in +1 -0
- dtlpymcp-0.1.7/PKG-INFO +82 -0
- dtlpymcp-0.1.7/README.md +65 -0
- dtlpymcp-0.1.7/dtlpymcp/__init__.py +7 -0
- dtlpymcp-0.1.7/dtlpymcp/__main__.py +34 -0
- dtlpymcp-0.1.7/dtlpymcp/default_sources.json +32 -0
- dtlpymcp-0.1.7/dtlpymcp/proxy.py +101 -0
- dtlpymcp-0.1.7/dtlpymcp/utils/dtlpy_context.py +244 -0
- dtlpymcp-0.1.7/dtlpymcp.egg-info/PKG-INFO +82 -0
- dtlpymcp-0.1.7/dtlpymcp.egg-info/SOURCES.txt +19 -0
- dtlpymcp-0.1.7/dtlpymcp.egg-info/dependency_links.txt +1 -0
- dtlpymcp-0.1.7/dtlpymcp.egg-info/entry_points.txt +2 -0
- dtlpymcp-0.1.7/dtlpymcp.egg-info/requires.txt +6 -0
- dtlpymcp-0.1.7/dtlpymcp.egg-info/top_level.txt +1 -0
- dtlpymcp-0.1.7/pyproject.toml +35 -0
- dtlpymcp-0.1.7/setup.cfg +4 -0
- dtlpymcp-0.1.7/tests/test_context.py +34 -0
- dtlpymcp-0.1.7/tests/test_custom_sources_file.py +34 -0
- dtlpymcp-0.1.7/tests/test_list_platform_tools.py +36 -0
- dtlpymcp-0.1.7/tests/test_proxy.py +39 -0
- dtlpymcp-0.1.7/tests/test_run.py +15 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include dtlpymcp/default_sources.json
|
dtlpymcp-0.1.7/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dtlpymcp
|
|
3
|
+
Version: 0.1.7
|
|
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": "uvx",
|
|
72
|
+
"args": ["dtlpymcp", "start"],
|
|
73
|
+
"env": {
|
|
74
|
+
"DATALOOP_ENV": "prod",
|
|
75
|
+
"DATALOOP_API_KEY": "API KEY"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Replace `API KEY` with your actual Dataloop API key.
|
dtlpymcp-0.1.7/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Dataloop MCP Proxy Server
|
|
2
|
+
|
|
3
|
+
This is the main proxy for the Dataloop Micro MCPs, installable as a Python package.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```shell
|
|
8
|
+
pip install git+<repository-url>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
You can run the proxy server via CLI:
|
|
14
|
+
|
|
15
|
+
```shell
|
|
16
|
+
dtlpymcp start
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or using Python module syntax:
|
|
20
|
+
|
|
21
|
+
```shell
|
|
22
|
+
python -m dtlpymcp start
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Local Development
|
|
26
|
+
|
|
27
|
+
- Requires Python 3.10+
|
|
28
|
+
- Install dependencies with `pip install -e .`
|
|
29
|
+
- Run tests with `pytest`
|
|
30
|
+
|
|
31
|
+
## Cursor MCP Integration
|
|
32
|
+
|
|
33
|
+
To add this MCP to Cursor, add the following to your configuration:
|
|
34
|
+
|
|
35
|
+
### Docker Example
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"mcpServers": {
|
|
39
|
+
"dataloop-ai-mcp": {
|
|
40
|
+
"command": "docker run -i --rm -e DATALOOP_API_KEY docker.io/dataloopai/mcp:latest",
|
|
41
|
+
"env": {
|
|
42
|
+
"DATALOOP_API_KEY": "API KEY"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Local CLI Example
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"dataloop-ai-mcp": {
|
|
54
|
+
"command": "uvx",
|
|
55
|
+
"args": ["dtlpymcp", "start"],
|
|
56
|
+
"env": {
|
|
57
|
+
"DATALOOP_ENV": "prod",
|
|
58
|
+
"DATALOOP_API_KEY": "API KEY"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Replace `API KEY` with your actual Dataloop API key.
|
|
@@ -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,32 @@
|
|
|
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
|
+
{
|
|
21
|
+
"dpk_name": "dataloop-mcp-data",
|
|
22
|
+
"app_url": null,
|
|
23
|
+
"server_url": null,
|
|
24
|
+
"app_jwt": null
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"dpk_name": "dataloop-mcp-dtlpy-sandbox",
|
|
28
|
+
"app_url": null,
|
|
29
|
+
"server_url": null,
|
|
30
|
+
"app_jwt": null
|
|
31
|
+
}
|
|
32
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
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 asyncio
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from dtlpymcp.utils.dtlpy_context import DataloopContext, SOURCES_FILEPATH
|
|
12
|
+
|
|
13
|
+
# Setup logging to both console and file with timestamp
|
|
14
|
+
log_dir = Path.home() / ".dataloop" / "mcplogs"
|
|
15
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
log_file = log_dir / f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.log"
|
|
17
|
+
|
|
18
|
+
# Remove any existing handlers
|
|
19
|
+
for handler in logging.root.handlers[:]:
|
|
20
|
+
logging.root.removeHandler(handler)
|
|
21
|
+
|
|
22
|
+
# File handler with timestamp
|
|
23
|
+
file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
|
|
24
|
+
file_handler.setFormatter(
|
|
25
|
+
logging.Formatter(fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Console handler (default format)
|
|
29
|
+
console_handler = logging.StreamHandler()
|
|
30
|
+
console_handler.setFormatter(logging.Formatter(fmt="[%(levelname)s] %(name)s: %(message)s"))
|
|
31
|
+
|
|
32
|
+
logging.basicConfig(level=logging.INFO, handlers=[file_handler, console_handler])
|
|
33
|
+
logger = logging.getLogger("[DATALOOP-MCP]")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ServerSettings(BaseSettings):
|
|
37
|
+
"""Settings for the Dataloop MCP server."""
|
|
38
|
+
|
|
39
|
+
model_config = SettingsConfigDict(env_prefix="MCP_DATALOOP_")
|
|
40
|
+
|
|
41
|
+
def __init__(self, **data):
|
|
42
|
+
super().__init__(**data)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def create_dataloop_mcp_server(settings: ServerSettings, sources_file: str) -> FastMCP:
|
|
46
|
+
"""Create a FastMCP server for Dataloop with Bearer token authentication."""
|
|
47
|
+
app = FastMCP(
|
|
48
|
+
name="Dataloop MCP Server",
|
|
49
|
+
instructions="A multi-tenant MCP server for Dataloop with authentication",
|
|
50
|
+
stateless_http=True,
|
|
51
|
+
debug=True,
|
|
52
|
+
)
|
|
53
|
+
dl_context = DataloopContext(token=os.environ.get('DATALOOP_API_KEY'),
|
|
54
|
+
env=os.environ.get('DATALOOP_ENV', 'prod'),
|
|
55
|
+
sources_file=sources_file)
|
|
56
|
+
|
|
57
|
+
# Initialize the Dataloop context
|
|
58
|
+
asyncio.run(dl_context.initialize())
|
|
59
|
+
|
|
60
|
+
@app.tool(description="Test tool for health checks")
|
|
61
|
+
async def test(ctx: Context, ping: Any = None) -> dict[str, Any]:
|
|
62
|
+
"""Health check tool. Returns status ok and echoes ping if provided."""
|
|
63
|
+
result = {"status": "ok"}
|
|
64
|
+
if ping is not None:
|
|
65
|
+
result["ping"] = ping
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
logger.info(f"Adding tools from {len(dl_context.mcp_sources)} sources")
|
|
69
|
+
|
|
70
|
+
for source in dl_context.mcp_sources:
|
|
71
|
+
logger.info(f"Adding tools from source: {source.dpk_name}")
|
|
72
|
+
for tool in source.tools:
|
|
73
|
+
app._tool_manager._tools[tool.name] = tool
|
|
74
|
+
logger.info(f"Registered tool: {tool.name}")
|
|
75
|
+
|
|
76
|
+
return app
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main(sources_file: Optional[str] = None) -> int:
|
|
80
|
+
logger.info("Starting Dataloop MCP server in stdio mode")
|
|
81
|
+
try:
|
|
82
|
+
settings = ServerSettings()
|
|
83
|
+
logger.info("Successfully configured Dataloop MCP server")
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Unexpected error during startup:\n{e}")
|
|
86
|
+
return 1
|
|
87
|
+
try:
|
|
88
|
+
if sources_file is None:
|
|
89
|
+
sources_file = SOURCES_FILEPATH
|
|
90
|
+
logger.info(f"Using sources file: {sources_file}")
|
|
91
|
+
mcp_server = create_dataloop_mcp_server(settings, sources_file)
|
|
92
|
+
logger.info("Starting Dataloop MCP server in stdio mode")
|
|
93
|
+
mcp_server.run(transport="stdio")
|
|
94
|
+
return 0
|
|
95
|
+
except Exception:
|
|
96
|
+
logger.error(f"Failed to start MCP server: {traceback.format_exc()}")
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
main()
|
|
@@ -0,0 +1,244 @@
|
|
|
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 dtlpymcp 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, env: str = 'prod'):
|
|
52
|
+
self._token = token
|
|
53
|
+
self.env = env
|
|
54
|
+
self.mcp_sources: List[MCPSource] = []
|
|
55
|
+
logger.info("DataloopContext initialized.")
|
|
56
|
+
if sources_file is None:
|
|
57
|
+
sources_file = SOURCES_FILEPATH
|
|
58
|
+
self.sources_file = sources_file
|
|
59
|
+
self.initialized = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def initialize(self, force: bool = False):
|
|
63
|
+
if not self.initialized or force:
|
|
64
|
+
await self.register_sources(self.sources_file)
|
|
65
|
+
self.initialized = True
|
|
66
|
+
|
|
67
|
+
async def register_sources(self, sources_file: str):
|
|
68
|
+
with open(sources_file, "r") as f:
|
|
69
|
+
data = json.load(f)
|
|
70
|
+
logger.info(f"Loading MCP sources from {sources_file}")
|
|
71
|
+
for entry in data:
|
|
72
|
+
try:
|
|
73
|
+
if not isinstance(entry, dict):
|
|
74
|
+
raise ValueError(f"Invalid source entry: {entry}")
|
|
75
|
+
logger.info(f"Adding MCP source: {entry.get('dpk_name')}, url: {entry.get('server_url')}")
|
|
76
|
+
await self.add_mcp_source(MCPSource(**entry))
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.error(f"Failed to add MCP source: {entry}\n{traceback.format_exc()}")
|
|
79
|
+
|
|
80
|
+
async def add_mcp_source(self, mcp_source: MCPSource):
|
|
81
|
+
self.mcp_sources.append(mcp_source)
|
|
82
|
+
if mcp_source.server_url is None:
|
|
83
|
+
self.load_app_info(mcp_source)
|
|
84
|
+
result = await self.list_source_tools(mcp_source)
|
|
85
|
+
if result is None:
|
|
86
|
+
raise ValueError(f"Failed to discover tools for source {mcp_source.dpk_name}")
|
|
87
|
+
server_name, tools, call_fn = result
|
|
88
|
+
for tool in tools.tools:
|
|
89
|
+
tool_name = tool.name
|
|
90
|
+
ns_tool_name = f"{server_name}.{tool_name}"
|
|
91
|
+
description = tool.description
|
|
92
|
+
input_schema = tool.inputSchema
|
|
93
|
+
|
|
94
|
+
def build_handler(tool_name):
|
|
95
|
+
async def inner(**kwargs):
|
|
96
|
+
fn = call_fn(tool_name, kwargs)
|
|
97
|
+
return await fn()
|
|
98
|
+
|
|
99
|
+
return inner
|
|
100
|
+
|
|
101
|
+
dynamic_pydantic_model_params = self.build_pydantic_fields_from_schema(input_schema)
|
|
102
|
+
arguments_model = create_model(
|
|
103
|
+
f"{tool_name}Arguments", **dynamic_pydantic_model_params, __base__=ArgModelBase
|
|
104
|
+
)
|
|
105
|
+
resp = FuncMetadata(arg_model=arguments_model)
|
|
106
|
+
t = Tool(
|
|
107
|
+
fn=build_handler(tool_name),
|
|
108
|
+
name=ns_tool_name,
|
|
109
|
+
description=description,
|
|
110
|
+
parameters=input_schema,
|
|
111
|
+
fn_metadata=resp,
|
|
112
|
+
is_async=True,
|
|
113
|
+
context_kwarg="ctx",
|
|
114
|
+
annotations=None,
|
|
115
|
+
)
|
|
116
|
+
mcp_source.tools.append(t)
|
|
117
|
+
tool_str = ", ".join([tool.name for tool in mcp_source.tools])
|
|
118
|
+
logger.info(f"Added MCP source: {mcp_source.dpk_name}, Available tools: {tool_str}")
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def token(self) -> str:
|
|
122
|
+
return self._token
|
|
123
|
+
|
|
124
|
+
@token.setter
|
|
125
|
+
def token(self, token: str):
|
|
126
|
+
self._token = token
|
|
127
|
+
|
|
128
|
+
def load_app_info(self, source: MCPSource) -> None:
|
|
129
|
+
"""
|
|
130
|
+
Get the source URL and app JWT for a given DPK name using Dataloop SDK.
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
dl.setenv(self.env)
|
|
134
|
+
dl.client_api.token = self.token
|
|
135
|
+
filters = dl.Filters(resource='apps')
|
|
136
|
+
filters.add(field="dpkName", values=source.dpk_name)
|
|
137
|
+
filters.add(field="scope", values="system")
|
|
138
|
+
apps = list(dl.apps.list(filters=filters).all())
|
|
139
|
+
if len(apps) == 0:
|
|
140
|
+
raise ValueError(f"No app found for DPK name: {source.dpk_name}")
|
|
141
|
+
if len(apps) > 1:
|
|
142
|
+
logger.warning(f"Multiple apps found for DPK name: {source.dpk_name}, using first one")
|
|
143
|
+
app = apps[0]
|
|
144
|
+
logger.info(f"App: {app.name}")
|
|
145
|
+
source.app_url = next(iter(app.routes.values()))
|
|
146
|
+
session = requests.Session()
|
|
147
|
+
response = session.get(source.app_url, headers=dl.client_api.auth)
|
|
148
|
+
logger.info(f"App route URL: {response.url}")
|
|
149
|
+
source.server_url = response.url
|
|
150
|
+
source.app_jwt = session.cookies.get("JWT-APP")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Failed getting app info: {traceback.format_exc()}")
|
|
153
|
+
raise Exception(f"Failed getting app info: {traceback.format_exc()}") from e
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def is_expired(app_jwt: str) -> bool:
|
|
157
|
+
"""
|
|
158
|
+
Check if the APP_JWT is expired.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
decoded = jwt.decode(app_jwt, options={"verify_signature": False})
|
|
162
|
+
if decoded.get("exp") < time.time():
|
|
163
|
+
return True
|
|
164
|
+
return False
|
|
165
|
+
except jwt.ExpiredSignatureError:
|
|
166
|
+
return True
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Error decoding JWT: {e}")
|
|
169
|
+
return True
|
|
170
|
+
|
|
171
|
+
def get_app_jwt(self, source: MCPSource, token: str) -> str:
|
|
172
|
+
"""
|
|
173
|
+
Get the APP_JWT from the request headers or refresh if expired.
|
|
174
|
+
"""
|
|
175
|
+
if source.app_jwt is None or self.is_expired(source.app_jwt):
|
|
176
|
+
try:
|
|
177
|
+
session = requests.Session()
|
|
178
|
+
response = session.get(source.app_url, headers={'authorization': 'Bearer ' + token})
|
|
179
|
+
source.app_jwt = session.cookies.get("JWT-APP")
|
|
180
|
+
except Exception:
|
|
181
|
+
logger.error(f"Failed getting app JWT from cookies\n{traceback.format_exc()}")
|
|
182
|
+
raise Exception(f"Failed getting app JWT from cookies\n{traceback.format_exc()}") from None
|
|
183
|
+
if not source.app_jwt:
|
|
184
|
+
logger.error(
|
|
185
|
+
"APP_JWT is missing. Please set the APP_JWT environment variable or ensure authentication is working."
|
|
186
|
+
)
|
|
187
|
+
raise ValueError(
|
|
188
|
+
"APP_JWT is missing. Please set the APP_JWT environment variable or ensure authentication is working."
|
|
189
|
+
)
|
|
190
|
+
return source.app_jwt
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def user_info(token: str) -> dict:
|
|
194
|
+
"""
|
|
195
|
+
Decode a JWT token and return user info.
|
|
196
|
+
"""
|
|
197
|
+
decoded = jwt.decode(token, options={"verify_signature": False})
|
|
198
|
+
return decoded
|
|
199
|
+
|
|
200
|
+
async def list_source_tools(self, source: MCPSource) -> Tuple[str, List[dict], Callable]:
|
|
201
|
+
"""
|
|
202
|
+
Discover tools for a given source and return (server_name, list_of_tools, call_fn).
|
|
203
|
+
"""
|
|
204
|
+
if source.server_url is None:
|
|
205
|
+
logger.error("DataloopContext required for DPK servers")
|
|
206
|
+
raise ValueError("DataloopContext required for DPK servers")
|
|
207
|
+
headers = {"Cookie": f"JWT-APP={source.app_jwt}",
|
|
208
|
+
"x-dl-info": f"{self.token}"}
|
|
209
|
+
async with streamablehttp_client(source.server_url, headers=headers) as (read, write, _):
|
|
210
|
+
async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as session:
|
|
211
|
+
await session.initialize()
|
|
212
|
+
tools = await session.list_tools()
|
|
213
|
+
|
|
214
|
+
def call_fn(tool_name, kwargs):
|
|
215
|
+
async def inner():
|
|
216
|
+
async with streamablehttp_client(source.server_url, headers=headers) as (read, write, _):
|
|
217
|
+
async with ClientSession(
|
|
218
|
+
read, write, read_timeout_seconds=timedelta(seconds=60)
|
|
219
|
+
) as session:
|
|
220
|
+
await session.initialize()
|
|
221
|
+
return await session.call_tool(tool_name, kwargs)
|
|
222
|
+
|
|
223
|
+
return inner
|
|
224
|
+
|
|
225
|
+
logger.info(f"Discovered {len(tools.tools)} tools for source {source.dpk_name}")
|
|
226
|
+
return (source.dpk_name, tools, call_fn)
|
|
227
|
+
|
|
228
|
+
def openapi_type_to_python(self, type_str):
|
|
229
|
+
return {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}.get(
|
|
230
|
+
type_str, str
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def build_pydantic_fields_from_schema(self, input_schema):
|
|
234
|
+
required = set(input_schema.get("required", []))
|
|
235
|
+
properties = input_schema.get("properties", {})
|
|
236
|
+
fields = {}
|
|
237
|
+
for name, prop in properties.items():
|
|
238
|
+
py_type = self.openapi_type_to_python(prop.get("type", "string"))
|
|
239
|
+
if name in required:
|
|
240
|
+
fields[name] = (py_type, Field(...))
|
|
241
|
+
else:
|
|
242
|
+
default = prop.get("default", None)
|
|
243
|
+
fields[name] = (py_type, Field(default=default))
|
|
244
|
+
return fields
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dtlpymcp
|
|
3
|
+
Version: 0.1.7
|
|
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": "uvx",
|
|
72
|
+
"args": ["dtlpymcp", "start"],
|
|
73
|
+
"env": {
|
|
74
|
+
"DATALOOP_ENV": "prod",
|
|
75
|
+
"DATALOOP_API_KEY": "API KEY"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Replace `API KEY` with your actual Dataloop API key.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
dtlpymcp/__init__.py
|
|
5
|
+
dtlpymcp/__main__.py
|
|
6
|
+
dtlpymcp/default_sources.json
|
|
7
|
+
dtlpymcp/proxy.py
|
|
8
|
+
dtlpymcp.egg-info/PKG-INFO
|
|
9
|
+
dtlpymcp.egg-info/SOURCES.txt
|
|
10
|
+
dtlpymcp.egg-info/dependency_links.txt
|
|
11
|
+
dtlpymcp.egg-info/entry_points.txt
|
|
12
|
+
dtlpymcp.egg-info/requires.txt
|
|
13
|
+
dtlpymcp.egg-info/top_level.txt
|
|
14
|
+
dtlpymcp/utils/dtlpy_context.py
|
|
15
|
+
tests/test_context.py
|
|
16
|
+
tests/test_custom_sources_file.py
|
|
17
|
+
tests/test_list_platform_tools.py
|
|
18
|
+
tests/test_proxy.py
|
|
19
|
+
tests/test_run.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dtlpymcp
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dtlpymcp"
|
|
7
|
+
version = "0.1.7"
|
|
8
|
+
description = "STDIO MCP proxy server for Dataloop platform."
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Your Name", email = "your.email@example.com" }
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"dtlpy",
|
|
21
|
+
"pydantic-settings",
|
|
22
|
+
"mcp",
|
|
23
|
+
"requests",
|
|
24
|
+
"pyjwt",
|
|
25
|
+
"makefun"
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
dtlpymcp = "dtlpymcp.__main__:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
include-package-data = true
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.package-data]
|
|
35
|
+
dtlpymcp = ["default_sources.json"]
|
dtlpymcp-0.1.7/setup.cfg
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from dtlpymcp import DataloopContext, MCPSource
|
|
2
|
+
import requests
|
|
3
|
+
import dtlpy as dl
|
|
4
|
+
import asyncio
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_url_and_headers():
|
|
8
|
+
dl_context = DataloopContext(token=dl.token())
|
|
9
|
+
dl_context.add_mcp_source(MCPSource(dpk_name="dataloop-mcp", app_url=None, app_jwt=None, server_url=None))
|
|
10
|
+
print(dl_context.mcp_sources[0].app_jwt)
|
|
11
|
+
print(dl_context.mcp_sources[0].server_url)
|
|
12
|
+
print(dl_context.mcp_sources[0].app_url)
|
|
13
|
+
headers = {"Cookie": f"JWT-APP={dl_context.mcp_sources[0].app_jwt}", "x-dl-info": dl_context.token}
|
|
14
|
+
health_check_url = dl_context.mcp_sources[0].server_url.replace("/mcp/", "/health")
|
|
15
|
+
response = requests.get(health_check_url, headers=headers)
|
|
16
|
+
print(response.json())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_discover_tools_for_server():
|
|
20
|
+
dl_context = DataloopContext(token=dl.token())
|
|
21
|
+
dl_context.add_mcp_source(MCPSource(dpk_name="dataloop-mcp", app_url=None, app_jwt=None, server_url=None))
|
|
22
|
+
|
|
23
|
+
result = asyncio.run(dl_context.discover_tools_for_server(dl_context.mcp_sources[0]))
|
|
24
|
+
print(result)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
import dtlpy as dl
|
|
29
|
+
|
|
30
|
+
dl.setenv('rc')
|
|
31
|
+
if dl.token_expired():
|
|
32
|
+
dl.login()
|
|
33
|
+
# test_url_and_headers()
|
|
34
|
+
test_discover_tools_for_server()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from mcp.client.stdio import stdio_client
|
|
7
|
+
from mcp import ClientSession, StdioServerParameters
|
|
8
|
+
import dtlpy as dl
|
|
9
|
+
|
|
10
|
+
# Create server parameters for stdio connection
|
|
11
|
+
server_params = StdioServerParameters(
|
|
12
|
+
command="dtlpymcp", # Executable
|
|
13
|
+
args=["start", "-s", "tests/assets/sources.json"], # Command line arguments
|
|
14
|
+
env=None, # Optional environment variables
|
|
15
|
+
cwd=os.getcwd()
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
async def test_health_check():
|
|
19
|
+
print("[TEST CLIENT] Connecting to MCP server and calling test tool...")
|
|
20
|
+
async with stdio_client(server=server_params) as (read, write):
|
|
21
|
+
async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as session:
|
|
22
|
+
await session.initialize()
|
|
23
|
+
num = random.randint(1, 1000000)
|
|
24
|
+
tool_result = await session.call_tool("test", {"ping": num})
|
|
25
|
+
print("[RESULT]", tool_result)
|
|
26
|
+
assert json.loads(tool_result.content[0].text).get("status") == "ok", "Health check failed!"
|
|
27
|
+
assert json.loads(tool_result.content[0].text).get("ping") == num, "Ping failed!"
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
dl.setenv('rc')
|
|
31
|
+
if dl.token_expired():
|
|
32
|
+
dl.login()
|
|
33
|
+
asyncio.run(test_health_check())
|
|
34
|
+
# asyncio.run(test_ask_dataloop())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from mcp.client.stdio import stdio_client
|
|
7
|
+
from mcp import ClientSession, StdioServerParameters
|
|
8
|
+
import dtlpy as dl
|
|
9
|
+
|
|
10
|
+
dl.setenv('rc')
|
|
11
|
+
if dl.token_expired():
|
|
12
|
+
dl.login()
|
|
13
|
+
# Create server parameters for stdio connection
|
|
14
|
+
server_params = StdioServerParameters(
|
|
15
|
+
command="dtlpymcp", # Executable
|
|
16
|
+
args=["start"], # Command line arguments
|
|
17
|
+
env={"DATALOOP_API_KEY": dl.token()}, # Optional environment variables
|
|
18
|
+
cwd=os.getcwd()
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
async def test_list_platform_tools():
|
|
22
|
+
print("[TEST CLIENT] Connecting to MCP server and calling test tool...")
|
|
23
|
+
async with stdio_client(server=server_params) as (read, write):
|
|
24
|
+
async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as session:
|
|
25
|
+
await session.initialize()
|
|
26
|
+
tools = await session.list_tools()
|
|
27
|
+
for tool in tools.tools:
|
|
28
|
+
tool_str = ' \n'.join([f"{k}: {v}" for k, v in tool.model_dump().items()])
|
|
29
|
+
print(f"Tool: {tool.name}")
|
|
30
|
+
print(tool_str)
|
|
31
|
+
print("-" * 50)
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
|
|
35
|
+
asyncio.run(test_list_platform_tools())
|
|
36
|
+
# asyncio.run(test_ask_dataloop())
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from mcp.client.stdio import stdio_client
|
|
7
|
+
from mcp import ClientSession, StdioServerParameters
|
|
8
|
+
import dtlpy as dl
|
|
9
|
+
|
|
10
|
+
dl.setenv('rc')
|
|
11
|
+
if dl.token_expired():
|
|
12
|
+
dl.login()
|
|
13
|
+
token = dl.token()
|
|
14
|
+
env = {"DATALOOP_API_KEY": str(token)} if token else None
|
|
15
|
+
# Create server parameters for stdio connection
|
|
16
|
+
server_params = StdioServerParameters(
|
|
17
|
+
command="dtlpymcp", # Executable
|
|
18
|
+
args=["start"], # Command line arguments
|
|
19
|
+
env=env, # Optional environment variables
|
|
20
|
+
cwd=os.getcwd(),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def test_health_check():
|
|
25
|
+
print("[TEST CLIENT] Connecting to MCP server and calling test tool...")
|
|
26
|
+
async with stdio_client(server=server_params) as (read, write):
|
|
27
|
+
async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as session:
|
|
28
|
+
await session.initialize()
|
|
29
|
+
num = random.randint(1, 1000000)
|
|
30
|
+
tool_result = await session.call_tool("test", {"ping": num})
|
|
31
|
+
print("[RESULT]", tool_result)
|
|
32
|
+
assert json.loads(tool_result.content[0].text).get("status") == "ok", "Health check failed!"
|
|
33
|
+
assert json.loads(tool_result.content[0].text).get("ping") == num, "Ping failed!"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
|
|
38
|
+
asyncio.run(test_health_check())
|
|
39
|
+
# asyncio.run(test_ask_dataloop())
|