dtlpymcp 0.1.7__tar.gz → 0.1.9__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 → dtlpymcp-0.1.9}/PKG-INFO +16 -1
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/README.md +15 -0
- dtlpymcp-0.1.9/dtlpymcp/__init__.py +34 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/dtlpymcp/__main__.py +14 -10
- dtlpymcp-0.1.9/dtlpymcp/min_proxy.py +75 -0
- dtlpymcp-0.1.9/dtlpymcp/proxy.py +129 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/dtlpymcp/utils/dtlpy_context.py +63 -56
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/dtlpymcp.egg-info/PKG-INFO +16 -1
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/dtlpymcp.egg-info/SOURCES.txt +1 -1
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/pyproject.toml +1 -1
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/tests/test_proxy.py +7 -5
- dtlpymcp-0.1.7/dtlpymcp/__init__.py +0 -7
- dtlpymcp-0.1.7/dtlpymcp/default_sources.json +0 -32
- dtlpymcp-0.1.7/dtlpymcp/proxy.py +0 -101
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/MANIFEST.in +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/dtlpymcp.egg-info/dependency_links.txt +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/dtlpymcp.egg-info/entry_points.txt +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/dtlpymcp.egg-info/requires.txt +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/dtlpymcp.egg-info/top_level.txt +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/setup.cfg +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/tests/test_context.py +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/tests/test_custom_sources_file.py +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/tests/test_list_platform_tools.py +0 -0
- {dtlpymcp-0.1.7 → dtlpymcp-0.1.9}/tests/test_run.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dtlpymcp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: STDIO MCP proxy server for Dataloop platform.
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -30,7 +30,14 @@ pip install git+<repository-url>
|
|
|
30
30
|
You can run the proxy server via CLI:
|
|
31
31
|
|
|
32
32
|
```shell
|
|
33
|
+
# Basic usage
|
|
33
34
|
dtlpymcp start
|
|
35
|
+
|
|
36
|
+
# With custom sources file
|
|
37
|
+
dtlpymcp start --sources-file /path/to/sources.json
|
|
38
|
+
|
|
39
|
+
# With custom initialization timeout (default: 30 seconds)
|
|
40
|
+
dtlpymcp start --init-timeout 60.0
|
|
34
41
|
```
|
|
35
42
|
|
|
36
43
|
Or using Python module syntax:
|
|
@@ -45,6 +52,14 @@ python -m dtlpymcp start
|
|
|
45
52
|
- Install dependencies with `pip install -e .`
|
|
46
53
|
- Run tests with `pytest`
|
|
47
54
|
|
|
55
|
+
## Architecture
|
|
56
|
+
|
|
57
|
+
The server uses a modular architecture with utilities for safe async initialization:
|
|
58
|
+
|
|
59
|
+
- `dtlpymcp/proxy.py` - Main server implementation using FastMCP
|
|
60
|
+
- `dtlpymcp/utils/server_utils.py` - Safe async initialization utilities
|
|
61
|
+
- `dtlpymcp/utils/dtlpy_context.py` - Dataloop context management
|
|
62
|
+
|
|
48
63
|
## Cursor MCP Integration
|
|
49
64
|
|
|
50
65
|
To add this MCP to Cursor, add the following to your configuration:
|
|
@@ -13,7 +13,14 @@ pip install git+<repository-url>
|
|
|
13
13
|
You can run the proxy server via CLI:
|
|
14
14
|
|
|
15
15
|
```shell
|
|
16
|
+
# Basic usage
|
|
16
17
|
dtlpymcp start
|
|
18
|
+
|
|
19
|
+
# With custom sources file
|
|
20
|
+
dtlpymcp start --sources-file /path/to/sources.json
|
|
21
|
+
|
|
22
|
+
# With custom initialization timeout (default: 30 seconds)
|
|
23
|
+
dtlpymcp start --init-timeout 60.0
|
|
17
24
|
```
|
|
18
25
|
|
|
19
26
|
Or using Python module syntax:
|
|
@@ -28,6 +35,14 @@ python -m dtlpymcp start
|
|
|
28
35
|
- Install dependencies with `pip install -e .`
|
|
29
36
|
- Run tests with `pytest`
|
|
30
37
|
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
The server uses a modular architecture with utilities for safe async initialization:
|
|
41
|
+
|
|
42
|
+
- `dtlpymcp/proxy.py` - Main server implementation using FastMCP
|
|
43
|
+
- `dtlpymcp/utils/server_utils.py` - Safe async initialization utilities
|
|
44
|
+
- `dtlpymcp/utils/dtlpy_context.py` - Dataloop context management
|
|
45
|
+
|
|
31
46
|
## Cursor MCP Integration
|
|
32
47
|
|
|
33
48
|
To add this MCP to Cursor, add the following to your configuration:
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .utils.dtlpy_context import DataloopContext, MCPSource
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.9"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Setup logging directory and file
|
|
10
|
+
log_dir = Path.home() / ".dataloop" / "mcplogs"
|
|
11
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
log_file = log_dir / f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.log"
|
|
13
|
+
|
|
14
|
+
# Remove any existing handlers
|
|
15
|
+
for handler in logging.root.handlers[:]:
|
|
16
|
+
logging.root.removeHandler(handler)
|
|
17
|
+
|
|
18
|
+
# File handler with timestamp
|
|
19
|
+
file_handler = logging.FileHandler(log_file, mode="a", encoding="utf-8")
|
|
20
|
+
file_handler.setFormatter(
|
|
21
|
+
logging.Formatter(fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Console handler (default format)
|
|
25
|
+
console_handler = logging.StreamHandler()
|
|
26
|
+
console_handler.setFormatter(logging.Formatter(fmt="[%(levelname)s] %(name)s: %(message)s"))
|
|
27
|
+
|
|
28
|
+
# Configure root logger
|
|
29
|
+
logging.basicConfig(level=logging.DEBUG, handlers=[file_handler, console_handler])
|
|
30
|
+
|
|
31
|
+
# Get the main logger
|
|
32
|
+
logger = logging.getLogger("dtlpymcp")
|
|
33
|
+
logger.info(f"Logging configured with level: DEBUG")
|
|
34
|
+
logger.info(f"Log file: {log_file}")
|
|
@@ -2,33 +2,37 @@
|
|
|
2
2
|
CLI entry point for dtlpymcp.
|
|
3
3
|
Reads from STDIN and writes to STDOUT.
|
|
4
4
|
"""
|
|
5
|
+
|
|
5
6
|
import sys
|
|
6
7
|
import argparse
|
|
7
8
|
from dtlpymcp.proxy import main as proxy_main
|
|
8
9
|
|
|
10
|
+
|
|
9
11
|
def main():
|
|
10
|
-
parser = argparse.ArgumentParser(
|
|
11
|
-
description="Dataloop MCP Proxy Server CLI"
|
|
12
|
-
)
|
|
12
|
+
parser = argparse.ArgumentParser(description="Dataloop MCP Proxy Server CLI")
|
|
13
13
|
subparsers = parser.add_subparsers(dest="command", required=False)
|
|
14
14
|
|
|
15
15
|
# 'start' subcommand
|
|
16
16
|
start_parser = subparsers.add_parser("start", help="Start the MCP proxy server (STDIO mode)")
|
|
17
17
|
start_parser.add_argument(
|
|
18
|
-
"--sources-file",
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
"--sources-file", "-s", type=str, default=None, help="Path to a JSON file with MCP sources to load"
|
|
19
|
+
)
|
|
20
|
+
start_parser.add_argument(
|
|
21
|
+
"--init-timeout",
|
|
22
|
+
"-t",
|
|
23
|
+
type=float,
|
|
24
|
+
default=30.0,
|
|
25
|
+
help="Timeout in seconds for Dataloop context initialization (default: 30.0)",
|
|
23
26
|
)
|
|
24
27
|
|
|
25
28
|
args = parser.parse_args()
|
|
26
29
|
|
|
27
30
|
if args.command == "start":
|
|
28
|
-
sys.exit(proxy_main(sources_file=args.sources_file))
|
|
31
|
+
sys.exit(proxy_main(sources_file=args.sources_file, init_timeout=args.init_timeout))
|
|
29
32
|
else:
|
|
30
33
|
parser.print_help()
|
|
31
34
|
return 1
|
|
32
35
|
|
|
36
|
+
|
|
33
37
|
if __name__ == "__main__":
|
|
34
|
-
main()
|
|
38
|
+
main()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP, Context
|
|
2
|
+
from typing import Any
|
|
3
|
+
import traceback
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
from mcp.server.fastmcp.tools.base import Tool, FuncMetadata
|
|
7
|
+
from pydantic import create_model
|
|
8
|
+
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase
|
|
9
|
+
from dtlpymcp.utils.dtlpy_context import DataloopContext
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_dataloop_mcp_server() -> FastMCP:
|
|
15
|
+
"""Create a FastMCP server for Dataloop with Bearer token authentication."""
|
|
16
|
+
|
|
17
|
+
async def test(ctx: Context, ping: Any = None) -> dict[str, Any]:
|
|
18
|
+
"""Health check tool. Returns status ok and echoes ping if provided."""
|
|
19
|
+
result = {"status": "ok"}
|
|
20
|
+
if ping is not None:
|
|
21
|
+
result["ping"] = ping
|
|
22
|
+
return result
|
|
23
|
+
|
|
24
|
+
app = FastMCP(
|
|
25
|
+
name="Dataloop MCP Server",
|
|
26
|
+
instructions="A multi-tenant MCP server for Dataloop with authentication",
|
|
27
|
+
debug=True,
|
|
28
|
+
log_level="DEBUG",
|
|
29
|
+
)
|
|
30
|
+
tool_name = "test"
|
|
31
|
+
input_schema = {"properties": {"ping": {"type": "string", "default": "pong"}}, "required": ["ping"]}
|
|
32
|
+
# Create Dataloop context
|
|
33
|
+
dynamic_pydantic_model_params = DataloopContext.build_pydantic_fields_from_schema(input_schema)
|
|
34
|
+
arguments_model = create_model(f"{tool_name}Arguments", **dynamic_pydantic_model_params, __base__=ArgModelBase)
|
|
35
|
+
resp = FuncMetadata(arg_model=arguments_model)
|
|
36
|
+
|
|
37
|
+
app._tool_manager._tools[tool_name] = Tool(
|
|
38
|
+
fn=test,
|
|
39
|
+
name=tool_name,
|
|
40
|
+
description="Test tool for health checks",
|
|
41
|
+
parameters=input_schema,
|
|
42
|
+
is_async=True,
|
|
43
|
+
context_kwarg="ctx",
|
|
44
|
+
fn_metadata=resp,
|
|
45
|
+
annotations=None,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
return app
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> int:
|
|
52
|
+
logger.info("Starting Dataloop MCP server in stdio mode")
|
|
53
|
+
|
|
54
|
+
# Validate environment variables
|
|
55
|
+
if not os.environ.get('DATALOOP_API_KEY'):
|
|
56
|
+
logger.error("DATALOOP_API_KEY environment variable is required")
|
|
57
|
+
return 1
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
mcp_server = create_dataloop_mcp_server()
|
|
61
|
+
logger.info("Dataloop MCP server created successfully")
|
|
62
|
+
logger.info("Starting server in stdio mode...")
|
|
63
|
+
mcp_server.run(transport="stdio")
|
|
64
|
+
return 0
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
logger.info("Server stopped by user")
|
|
67
|
+
return 0
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f"Failed to start MCP server: {e}")
|
|
70
|
+
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP, Context
|
|
2
|
+
from mcp.server.fastmcp.tools.base import Tool, FuncMetadata
|
|
3
|
+
from typing import Any, Optional, List
|
|
4
|
+
import traceback
|
|
5
|
+
import os
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
from pydantic import create_model
|
|
9
|
+
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase
|
|
10
|
+
|
|
11
|
+
from dtlpymcp.utils.dtlpy_context import DataloopContext
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("dtlpymcp")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_async(coro):
|
|
17
|
+
try:
|
|
18
|
+
loop = asyncio.get_running_loop()
|
|
19
|
+
except RuntimeError:
|
|
20
|
+
# No event loop running
|
|
21
|
+
return asyncio.run(coro)
|
|
22
|
+
else:
|
|
23
|
+
# Already running event loop
|
|
24
|
+
return loop.create_task(coro)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def initialize_dataloop_context(sources_file: Optional[str] = None, init_timeout: float = 30.0) -> List[Tool]:
|
|
28
|
+
"""
|
|
29
|
+
Initialize Dataloop context with timeout protection.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
dl_context: The DataloopContext instance to initialize
|
|
33
|
+
app: The FastMCP app instance to register tools with
|
|
34
|
+
init_timeout: Timeout in seconds for initialization
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List[Tool]: List of tools
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
tools = []
|
|
41
|
+
dl_context = DataloopContext(
|
|
42
|
+
token=os.environ.get('DATALOOP_API_KEY'),
|
|
43
|
+
env=os.environ.get('DATALOOP_ENV', 'prod'),
|
|
44
|
+
sources_file=sources_file,
|
|
45
|
+
)
|
|
46
|
+
logger.info("Initializing Dataloop context...")
|
|
47
|
+
await dl_context.initialize()
|
|
48
|
+
logger.info("Dataloop context initialized successfully")
|
|
49
|
+
|
|
50
|
+
logger.info(f"Adding tools from {len(dl_context.mcp_sources)} sources")
|
|
51
|
+
for source in dl_context.mcp_sources:
|
|
52
|
+
logger.info(f"Adding tools from source: {source.dpk_name}")
|
|
53
|
+
for tool in source.tools:
|
|
54
|
+
tools.append(tool)
|
|
55
|
+
logger.info(f"Registered tool: {tool.name}")
|
|
56
|
+
|
|
57
|
+
return tools
|
|
58
|
+
|
|
59
|
+
except asyncio.TimeoutError:
|
|
60
|
+
logger.error("Timeout during Dataloop context initialization")
|
|
61
|
+
return []
|
|
62
|
+
except Exception as e:
|
|
63
|
+
logger.error(f"Failed to initialize Dataloop context: {e}")
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_dataloop_mcp_server(sources_file: Optional[str] = None, init_timeout: float = 30.0) -> FastMCP:
|
|
68
|
+
"""Create a FastMCP server for Dataloop with Bearer token authentication."""
|
|
69
|
+
|
|
70
|
+
async def test(ctx: Context, ping: Any = None) -> dict[str, Any]:
|
|
71
|
+
"""Health check tool. Returns status ok and echoes ping if provided."""
|
|
72
|
+
result = {"status": "ok"}
|
|
73
|
+
if ping is not None:
|
|
74
|
+
result["ping"] = ping
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
tool_name = "test"
|
|
78
|
+
input_schema = {"properties": {"ping": {"type": "string"}}, "required": ["ping"]}
|
|
79
|
+
dynamic_pydantic_model_params = DataloopContext.build_pydantic_fields_from_schema(input_schema)
|
|
80
|
+
arguments_model = create_model(f"{tool_name}Arguments", **dynamic_pydantic_model_params, __base__=ArgModelBase)
|
|
81
|
+
resp = FuncMetadata(arg_model=arguments_model)
|
|
82
|
+
t = Tool(
|
|
83
|
+
fn=test,
|
|
84
|
+
name=tool_name,
|
|
85
|
+
description="Test tool for health checks",
|
|
86
|
+
parameters=input_schema,
|
|
87
|
+
is_async=True,
|
|
88
|
+
context_kwarg="ctx",
|
|
89
|
+
fn_metadata=resp,
|
|
90
|
+
annotations=None,
|
|
91
|
+
)
|
|
92
|
+
tools = [t]
|
|
93
|
+
tools.extend(run_async(initialize_dataloop_context(sources_file=sources_file, init_timeout=init_timeout)))
|
|
94
|
+
app = FastMCP(
|
|
95
|
+
name="Dataloop MCP Server",
|
|
96
|
+
instructions="A multi-tenant MCP server for Dataloop with authentication",
|
|
97
|
+
debug=True,
|
|
98
|
+
tools=tools,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return app
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(sources_file: Optional[str] = None, init_timeout: float = 30.0) -> int:
|
|
105
|
+
|
|
106
|
+
logger.info("Starting Dataloop MCP server in stdio mode")
|
|
107
|
+
|
|
108
|
+
# Validate environment variables
|
|
109
|
+
if not os.environ.get('DATALOOP_API_KEY'):
|
|
110
|
+
logger.error("DATALOOP_API_KEY environment variable is required")
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
mcp_server = create_dataloop_mcp_server(sources_file=sources_file, init_timeout=init_timeout)
|
|
115
|
+
logger.info("Dataloop MCP server created successfully")
|
|
116
|
+
logger.info("Starting server in stdio mode...")
|
|
117
|
+
mcp_server.run(transport="stdio")
|
|
118
|
+
return 0
|
|
119
|
+
except KeyboardInterrupt:
|
|
120
|
+
logger.info("Server stopped by user")
|
|
121
|
+
return 0
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"Failed to start MCP server: {e}")
|
|
124
|
+
logger.error(f"Traceback: {traceback.format_exc()}")
|
|
125
|
+
return 1
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
|
|
2
2
|
from mcp.client.streamable_http import streamablehttp_client
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import List, Tuple, Callable, Optional
|
|
4
4
|
from mcp.server.fastmcp.tools.base import Tool
|
|
5
|
-
from mcp.server.fastmcp import FastMCP
|
|
6
5
|
from pydantic import BaseModel, Field
|
|
7
6
|
from pydantic import create_model
|
|
8
7
|
from datetime import timedelta
|
|
@@ -11,27 +10,11 @@ import dtlpy as dl
|
|
|
11
10
|
import traceback
|
|
12
11
|
import requests
|
|
13
12
|
import logging
|
|
14
|
-
import asyncio
|
|
15
13
|
import time
|
|
16
14
|
import jwt
|
|
17
15
|
import json
|
|
18
|
-
from dtlpymcp import SOURCES_FILEPATH
|
|
19
16
|
|
|
20
|
-
|
|
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__)
|
|
17
|
+
logger = logging.getLogger("dtlpymcp")
|
|
35
18
|
|
|
36
19
|
|
|
37
20
|
class MCPSource(BaseModel):
|
|
@@ -53,22 +36,41 @@ class DataloopContext:
|
|
|
53
36
|
self.env = env
|
|
54
37
|
self.mcp_sources: List[MCPSource] = []
|
|
55
38
|
logger.info("DataloopContext initialized.")
|
|
56
|
-
if sources_file is None:
|
|
57
|
-
sources_file = SOURCES_FILEPATH
|
|
58
39
|
self.sources_file = sources_file
|
|
59
40
|
self.initialized = False
|
|
60
|
-
|
|
61
|
-
|
|
41
|
+
|
|
62
42
|
async def initialize(self, force: bool = False):
|
|
63
|
-
if not self.initialized or force:
|
|
43
|
+
if not self.initialized or force:
|
|
64
44
|
await self.register_sources(self.sources_file)
|
|
65
45
|
self.initialized = True
|
|
66
46
|
|
|
67
|
-
async def register_sources(self, sources_file: str):
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
47
|
+
async def register_sources(self, sources_file: str = None):
|
|
48
|
+
if sources_file is None:
|
|
49
|
+
logger.info("Loading MCP sources from all system apps")
|
|
50
|
+
# load all system apps
|
|
51
|
+
filters = dl.Filters(resource='apps')
|
|
52
|
+
filters.add(field="dpkName", values="dataloop-mcp*")
|
|
53
|
+
filters.add(field="scope", values="system")
|
|
54
|
+
# IMPORTANT: Listing with `all()` cause everything to get stuck. getting only first page using `items` for now
|
|
55
|
+
apps = dl.apps.list(filters=filters).items
|
|
56
|
+
if len(apps) == 0:
|
|
57
|
+
raise ValueError(f"No app found for DPK name: dataloop-mcp*")
|
|
58
|
+
sources = []
|
|
59
|
+
for app in apps:
|
|
60
|
+
sources.append(
|
|
61
|
+
{
|
|
62
|
+
"dpk_name": app.dpk_name,
|
|
63
|
+
"app_url": next(iter(app.routes.values())),
|
|
64
|
+
"server_url": None,
|
|
65
|
+
"app_jwt": None,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
logger.info(f"Loading MCP sources from {sources_file}")
|
|
70
|
+
|
|
71
|
+
with open(sources_file, "r") as f:
|
|
72
|
+
sources = json.load(f)
|
|
73
|
+
for entry in sources:
|
|
72
74
|
try:
|
|
73
75
|
if not isinstance(entry, dict):
|
|
74
76
|
raise ValueError(f"Invalid source entry: {entry}")
|
|
@@ -78,9 +80,12 @@ class DataloopContext:
|
|
|
78
80
|
logger.error(f"Failed to add MCP source: {entry}\n{traceback.format_exc()}")
|
|
79
81
|
|
|
80
82
|
async def add_mcp_source(self, mcp_source: MCPSource):
|
|
81
|
-
|
|
83
|
+
|
|
82
84
|
if mcp_source.server_url is None:
|
|
83
|
-
self.load_app_info(mcp_source)
|
|
85
|
+
success = self.load_app_info(mcp_source)
|
|
86
|
+
if not success:
|
|
87
|
+
logger.error(f"Failed to load app info for source {mcp_source.dpk_name}")
|
|
88
|
+
return
|
|
84
89
|
result = await self.list_source_tools(mcp_source)
|
|
85
90
|
if result is None:
|
|
86
91
|
raise ValueError(f"Failed to discover tools for source {mcp_source.dpk_name}")
|
|
@@ -114,6 +119,7 @@ class DataloopContext:
|
|
|
114
119
|
annotations=None,
|
|
115
120
|
)
|
|
116
121
|
mcp_source.tools.append(t)
|
|
122
|
+
self.mcp_sources.append(mcp_source)
|
|
117
123
|
tool_str = ", ".join([tool.name for tool in mcp_source.tools])
|
|
118
124
|
logger.info(f"Added MCP source: {mcp_source.dpk_name}, Available tools: {tool_str}")
|
|
119
125
|
|
|
@@ -125,32 +131,34 @@ class DataloopContext:
|
|
|
125
131
|
def token(self, token: str):
|
|
126
132
|
self._token = token
|
|
127
133
|
|
|
128
|
-
def load_app_info(self, source: MCPSource) ->
|
|
134
|
+
def load_app_info(self, source: MCPSource) -> bool:
|
|
129
135
|
"""
|
|
130
136
|
Get the source URL and app JWT for a given DPK name using Dataloop SDK.
|
|
131
137
|
"""
|
|
132
138
|
try:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
139
|
+
if source.app_url is None:
|
|
140
|
+
dl.setenv(self.env)
|
|
141
|
+
dl.client_api.token = self.token
|
|
142
|
+
filters = dl.Filters(resource='apps')
|
|
143
|
+
filters.add(field="dpkName", values=source.dpk_name)
|
|
144
|
+
filters.add(field="scope", values="system")
|
|
145
|
+
apps = list(dl.apps.list(filters=filters).all())
|
|
146
|
+
if len(apps) == 0:
|
|
147
|
+
raise ValueError(f"No app found for DPK name: {source.dpk_name}")
|
|
148
|
+
if len(apps) > 1:
|
|
149
|
+
logger.warning(f"Multiple apps found for DPK name: {source.dpk_name}, using first one")
|
|
150
|
+
app = apps[0]
|
|
151
|
+
logger.info(f"App: {app.name}")
|
|
152
|
+
source.app_url = next(iter(app.routes.values()))
|
|
146
153
|
session = requests.Session()
|
|
147
154
|
response = session.get(source.app_url, headers=dl.client_api.auth)
|
|
148
155
|
logger.info(f"App route URL: {response.url}")
|
|
149
156
|
source.server_url = response.url
|
|
150
157
|
source.app_jwt = session.cookies.get("JWT-APP")
|
|
151
|
-
except Exception
|
|
158
|
+
except Exception:
|
|
152
159
|
logger.error(f"Failed getting app info: {traceback.format_exc()}")
|
|
153
|
-
|
|
160
|
+
return False
|
|
161
|
+
return True
|
|
154
162
|
|
|
155
163
|
@staticmethod
|
|
156
164
|
def is_expired(app_jwt: str) -> bool:
|
|
@@ -172,18 +180,16 @@ class DataloopContext:
|
|
|
172
180
|
"""
|
|
173
181
|
Get the APP_JWT from the request headers or refresh if expired.
|
|
174
182
|
"""
|
|
183
|
+
if source.app_url is None:
|
|
184
|
+
raise ValueError("App URL is missing. Please set the app URL.")
|
|
175
185
|
if source.app_jwt is None or self.is_expired(source.app_jwt):
|
|
176
186
|
try:
|
|
177
187
|
session = requests.Session()
|
|
178
188
|
response = session.get(source.app_url, headers={'authorization': 'Bearer ' + token})
|
|
179
189
|
source.app_jwt = session.cookies.get("JWT-APP")
|
|
180
190
|
except Exception:
|
|
181
|
-
logger.error(f"Failed getting app JWT from cookies\n{traceback.format_exc()}")
|
|
182
191
|
raise Exception(f"Failed getting app JWT from cookies\n{traceback.format_exc()}") from None
|
|
183
192
|
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
193
|
raise ValueError(
|
|
188
194
|
"APP_JWT is missing. Please set the APP_JWT environment variable or ensure authentication is working."
|
|
189
195
|
)
|
|
@@ -204,8 +210,7 @@ class DataloopContext:
|
|
|
204
210
|
if source.server_url is None:
|
|
205
211
|
logger.error("DataloopContext required for DPK servers")
|
|
206
212
|
raise ValueError("DataloopContext required for DPK servers")
|
|
207
|
-
headers = {"Cookie": f"JWT-APP={source.app_jwt}",
|
|
208
|
-
"x-dl-info": f"{self.token}"}
|
|
213
|
+
headers = {"Cookie": f"JWT-APP={source.app_jwt}", "x-dl-info": f"{self.token}"}
|
|
209
214
|
async with streamablehttp_client(source.server_url, headers=headers) as (read, write, _):
|
|
210
215
|
async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as session:
|
|
211
216
|
await session.initialize()
|
|
@@ -225,17 +230,19 @@ class DataloopContext:
|
|
|
225
230
|
logger.info(f"Discovered {len(tools.tools)} tools for source {source.dpk_name}")
|
|
226
231
|
return (source.dpk_name, tools, call_fn)
|
|
227
232
|
|
|
228
|
-
|
|
233
|
+
@staticmethod
|
|
234
|
+
def openapi_type_to_python(type_str):
|
|
229
235
|
return {"string": str, "integer": int, "number": float, "boolean": bool, "array": list, "object": dict}.get(
|
|
230
236
|
type_str, str
|
|
231
237
|
)
|
|
232
238
|
|
|
233
|
-
|
|
239
|
+
@staticmethod
|
|
240
|
+
def build_pydantic_fields_from_schema(input_schema):
|
|
234
241
|
required = set(input_schema.get("required", []))
|
|
235
242
|
properties = input_schema.get("properties", {})
|
|
236
243
|
fields = {}
|
|
237
244
|
for name, prop in properties.items():
|
|
238
|
-
py_type =
|
|
245
|
+
py_type = DataloopContext.openapi_type_to_python(prop.get("type", "string"))
|
|
239
246
|
if name in required:
|
|
240
247
|
fields[name] = (py_type, Field(...))
|
|
241
248
|
else:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dtlpymcp
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: STDIO MCP proxy server for Dataloop platform.
|
|
5
5
|
Author-email: Your Name <your.email@example.com>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3.10
|
|
@@ -30,7 +30,14 @@ pip install git+<repository-url>
|
|
|
30
30
|
You can run the proxy server via CLI:
|
|
31
31
|
|
|
32
32
|
```shell
|
|
33
|
+
# Basic usage
|
|
33
34
|
dtlpymcp start
|
|
35
|
+
|
|
36
|
+
# With custom sources file
|
|
37
|
+
dtlpymcp start --sources-file /path/to/sources.json
|
|
38
|
+
|
|
39
|
+
# With custom initialization timeout (default: 30 seconds)
|
|
40
|
+
dtlpymcp start --init-timeout 60.0
|
|
34
41
|
```
|
|
35
42
|
|
|
36
43
|
Or using Python module syntax:
|
|
@@ -45,6 +52,14 @@ python -m dtlpymcp start
|
|
|
45
52
|
- Install dependencies with `pip install -e .`
|
|
46
53
|
- Run tests with `pytest`
|
|
47
54
|
|
|
55
|
+
## Architecture
|
|
56
|
+
|
|
57
|
+
The server uses a modular architecture with utilities for safe async initialization:
|
|
58
|
+
|
|
59
|
+
- `dtlpymcp/proxy.py` - Main server implementation using FastMCP
|
|
60
|
+
- `dtlpymcp/utils/server_utils.py` - Safe async initialization utilities
|
|
61
|
+
- `dtlpymcp/utils/dtlpy_context.py` - Dataloop context management
|
|
62
|
+
|
|
48
63
|
## Cursor MCP Integration
|
|
49
64
|
|
|
50
65
|
To add this MCP to Cursor, add the following to your configuration:
|
|
@@ -1,32 +1,34 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import random
|
|
3
3
|
import json
|
|
4
|
+
import sys
|
|
4
5
|
import os
|
|
5
6
|
from datetime import timedelta
|
|
6
7
|
from mcp.client.stdio import stdio_client
|
|
7
8
|
from mcp import ClientSession, StdioServerParameters
|
|
8
9
|
import dtlpy as dl
|
|
9
10
|
|
|
10
|
-
dl.setenv('
|
|
11
|
+
dl.setenv('prod')
|
|
11
12
|
if dl.token_expired():
|
|
12
13
|
dl.login()
|
|
13
14
|
token = dl.token()
|
|
14
15
|
env = {"DATALOOP_API_KEY": str(token)} if token else None
|
|
15
16
|
# Create server parameters for stdio connection
|
|
16
17
|
server_params = StdioServerParameters(
|
|
17
|
-
command=
|
|
18
|
-
args=["
|
|
18
|
+
command=sys.executable, # Use current Python interpreter
|
|
19
|
+
args=["-m", "dtlpymcp.min_proxy"], # Run as module with start command
|
|
19
20
|
env=env, # Optional environment variables
|
|
20
21
|
cwd=os.getcwd(),
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
|
|
24
26
|
async def test_health_check():
|
|
25
27
|
print("[TEST CLIENT] Connecting to MCP server and calling test tool...")
|
|
26
28
|
async with stdio_client(server=server_params) as (read, write):
|
|
27
29
|
async with ClientSession(read, write, read_timeout_seconds=timedelta(seconds=60)) as session:
|
|
28
30
|
await session.initialize()
|
|
29
|
-
num = random.randint(1, 1000000)
|
|
31
|
+
num = str(random.randint(1, 1000000))
|
|
30
32
|
tool_result = await session.call_tool("test", {"ping": num})
|
|
31
33
|
print("[RESULT]", tool_result)
|
|
32
34
|
assert json.loads(tool_result.content[0].text).get("status") == "ok", "Health check failed!"
|
|
@@ -34,6 +36,6 @@ async def test_health_check():
|
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
if __name__ == "__main__":
|
|
39
|
+
|
|
37
40
|
|
|
38
41
|
asyncio.run(test_health_check())
|
|
39
|
-
# asyncio.run(test_ask_dataloop())
|
|
@@ -1,32 +0,0 @@
|
|
|
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
|
-
]
|
dtlpymcp-0.1.7/dtlpymcp/proxy.py
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|