marcopolo-sdk 0.1.1__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.
- marcopolo/__init__.py +29 -0
- marcopolo/_version.py +3 -0
- marcopolo/client.py +121 -0
- marcopolo/execution.py +267 -0
- marcopolo/mcp_transport.py +109 -0
- marcopolo/py.typed +1 -0
- marcopolo/query_files.py +170 -0
- marcopolo_sdk-0.1.1.dist-info/METADATA +503 -0
- marcopolo_sdk-0.1.1.dist-info/RECORD +10 -0
- marcopolo_sdk-0.1.1.dist-info/WHEEL +4 -0
marcopolo/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Public package surface for the MarcoPolo Python SDK."""
|
|
2
|
+
|
|
3
|
+
from marcopolo._version import __version__
|
|
4
|
+
from marcopolo.client import MarcoPolo
|
|
5
|
+
from marcopolo.execution import (
|
|
6
|
+
ConnectionListResult,
|
|
7
|
+
ConnectionSummary,
|
|
8
|
+
ExecutionError,
|
|
9
|
+
ExecutionResult,
|
|
10
|
+
)
|
|
11
|
+
from marcopolo.mcp_transport import MarcoPoloMCPTransport
|
|
12
|
+
from marcopolo.query_files import (
|
|
13
|
+
AuthoredQueryFile,
|
|
14
|
+
MarcoPoloQueryFileAuthor,
|
|
15
|
+
PreparedQueryFile,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"MarcoPolo",
|
|
20
|
+
"ConnectionListResult",
|
|
21
|
+
"ConnectionSummary",
|
|
22
|
+
"ExecutionError",
|
|
23
|
+
"ExecutionResult",
|
|
24
|
+
"MarcoPoloMCPTransport",
|
|
25
|
+
"MarcoPoloQueryFileAuthor",
|
|
26
|
+
"PreparedQueryFile",
|
|
27
|
+
"AuthoredQueryFile",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
marcopolo/_version.py
ADDED
marcopolo/client.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Top-level client entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from marcopolo.execution import (
|
|
8
|
+
ConnectionListResult,
|
|
9
|
+
ExecutionResult,
|
|
10
|
+
build_connection_list_command,
|
|
11
|
+
build_connection_query_command,
|
|
12
|
+
parse_workspace_shell_connection_list_result,
|
|
13
|
+
parse_workspace_shell_query_result,
|
|
14
|
+
)
|
|
15
|
+
from marcopolo.mcp_transport import MarcoPoloMCPTransport
|
|
16
|
+
from marcopolo.query_files import MarcoPoloQueryFileAuthor, PayloadFormat
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MarcoPolo:
|
|
20
|
+
"""MarcoPolo client entry point.
|
|
21
|
+
|
|
22
|
+
Callers must pass explicit connection settings. Environment management stays
|
|
23
|
+
outside the library.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_token: str,
|
|
29
|
+
server_url: str,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Create a client from explicit settings."""
|
|
32
|
+
|
|
33
|
+
self.api_token = api_token
|
|
34
|
+
self.server_url = server_url
|
|
35
|
+
|
|
36
|
+
def transport(self) -> MarcoPoloMCPTransport:
|
|
37
|
+
"""Create a low-level MCP transport configured for this client."""
|
|
38
|
+
|
|
39
|
+
return MarcoPoloMCPTransport(
|
|
40
|
+
api_token=self.api_token,
|
|
41
|
+
server_url=self.server_url,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def query_file_author(self) -> MarcoPoloQueryFileAuthor:
|
|
45
|
+
"""Create the internal remote query-file authoring helper."""
|
|
46
|
+
|
|
47
|
+
return MarcoPoloQueryFileAuthor(self.transport())
|
|
48
|
+
|
|
49
|
+
async def execute(
|
|
50
|
+
self,
|
|
51
|
+
connection_name: str,
|
|
52
|
+
payload: dict[str, Any] | list[Any] | str,
|
|
53
|
+
*,
|
|
54
|
+
query_name: str,
|
|
55
|
+
context: str,
|
|
56
|
+
payload_format: PayloadFormat | None = None,
|
|
57
|
+
params: dict[str, Any] | None = None,
|
|
58
|
+
timeout: int | None = None,
|
|
59
|
+
) -> ExecutionResult:
|
|
60
|
+
"""Author a query file remotely and execute it with `connection query`."""
|
|
61
|
+
|
|
62
|
+
transport = self.transport()
|
|
63
|
+
authored = await MarcoPoloQueryFileAuthor(transport).author(
|
|
64
|
+
connection_name,
|
|
65
|
+
payload,
|
|
66
|
+
context=context,
|
|
67
|
+
query_name=query_name,
|
|
68
|
+
payload_format=payload_format,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
)
|
|
71
|
+
return await self.execute_query_file(
|
|
72
|
+
connection_name,
|
|
73
|
+
authored.query_file,
|
|
74
|
+
context=context,
|
|
75
|
+
params=params,
|
|
76
|
+
timeout=timeout,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async def execute_query_file(
|
|
80
|
+
self,
|
|
81
|
+
connection_name: str,
|
|
82
|
+
query_file: str,
|
|
83
|
+
*,
|
|
84
|
+
context: str,
|
|
85
|
+
params: dict[str, Any] | None = None,
|
|
86
|
+
timeout: int | None = None,
|
|
87
|
+
) -> ExecutionResult:
|
|
88
|
+
"""Execute an existing remote query file through `connection query`."""
|
|
89
|
+
|
|
90
|
+
transport = self.transport()
|
|
91
|
+
command = build_connection_query_command(
|
|
92
|
+
connection_name,
|
|
93
|
+
query_file,
|
|
94
|
+
params=params,
|
|
95
|
+
)
|
|
96
|
+
tool_result = await transport.workspace_shell(
|
|
97
|
+
command=command,
|
|
98
|
+
context=context,
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
)
|
|
101
|
+
return parse_workspace_shell_query_result(
|
|
102
|
+
tool_result,
|
|
103
|
+
connection_name=connection_name,
|
|
104
|
+
query_file=query_file,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def list_connections(
|
|
108
|
+
self,
|
|
109
|
+
*,
|
|
110
|
+
context: str,
|
|
111
|
+
timeout: int | None = None,
|
|
112
|
+
) -> ConnectionListResult:
|
|
113
|
+
"""List available MarcoPolo connections through `connection list`."""
|
|
114
|
+
|
|
115
|
+
transport = self.transport()
|
|
116
|
+
tool_result = await transport.workspace_shell(
|
|
117
|
+
command=build_connection_list_command(),
|
|
118
|
+
context=context,
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
)
|
|
121
|
+
return parse_workspace_shell_connection_list_result(tool_result)
|
marcopolo/execution.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Execution helpers for running remote MarcoPolo connection commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shlex
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class ExecutionResult:
|
|
13
|
+
"""Normalized result from a MarcoPolo `connection query` execution."""
|
|
14
|
+
|
|
15
|
+
connection_name: str
|
|
16
|
+
query_file: str
|
|
17
|
+
rows: list[dict[str, Any]]
|
|
18
|
+
row_count: int
|
|
19
|
+
run_id: str | None
|
|
20
|
+
raw_payload: dict[str, Any]
|
|
21
|
+
raw_command_result: dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ConnectionSummary:
|
|
26
|
+
"""Normalized metadata for one MarcoPolo connection."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
connection_type: str
|
|
30
|
+
capabilities: list[str]
|
|
31
|
+
display_name: str | None
|
|
32
|
+
workspace_path: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ConnectionListResult:
|
|
37
|
+
"""Normalized result from a MarcoPolo `connection list` execution."""
|
|
38
|
+
|
|
39
|
+
connections: list[ConnectionSummary]
|
|
40
|
+
count: int
|
|
41
|
+
message: str | None
|
|
42
|
+
next_actions: list[str]
|
|
43
|
+
raw_payload: dict[str, Any]
|
|
44
|
+
raw_command_result: dict[str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ExecutionError(RuntimeError):
|
|
48
|
+
"""Raised when remote command execution or result parsing fails."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_connection_query_command(
|
|
52
|
+
connection_name: str,
|
|
53
|
+
query_file: str,
|
|
54
|
+
*,
|
|
55
|
+
params: dict[str, Any] | None = None,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Build a `connection query` CLI invocation."""
|
|
58
|
+
|
|
59
|
+
command_parts = [
|
|
60
|
+
"connection",
|
|
61
|
+
"query",
|
|
62
|
+
shlex.quote(connection_name),
|
|
63
|
+
"--file",
|
|
64
|
+
shlex.quote(query_file),
|
|
65
|
+
"--json",
|
|
66
|
+
"--sample-rows",
|
|
67
|
+
"-1",
|
|
68
|
+
]
|
|
69
|
+
if params is not None:
|
|
70
|
+
command_parts.extend(["--params-json", shlex.quote(json.dumps(params))])
|
|
71
|
+
return " ".join(command_parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_connection_list_command() -> str:
|
|
75
|
+
"""Build a `connection list` CLI invocation."""
|
|
76
|
+
|
|
77
|
+
return "connection list --json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_workspace_shell_query_result(
|
|
81
|
+
tool_result: Any,
|
|
82
|
+
*,
|
|
83
|
+
connection_name: str,
|
|
84
|
+
query_file: str,
|
|
85
|
+
) -> ExecutionResult:
|
|
86
|
+
"""Normalize a `workspace_shell` result for `connection query --json`."""
|
|
87
|
+
|
|
88
|
+
command_result = _structured_mapping(tool_result)
|
|
89
|
+
payload = _parse_command_payload(
|
|
90
|
+
command_result,
|
|
91
|
+
empty_output_message=f"Query on connection '{connection_name}' produced no output.",
|
|
92
|
+
failure_message=f"Failed to execute query for connection '{connection_name}'.",
|
|
93
|
+
)
|
|
94
|
+
if payload.get("success") is False:
|
|
95
|
+
raise ExecutionError(
|
|
96
|
+
(
|
|
97
|
+
payload.get("message")
|
|
98
|
+
or payload.get("error")
|
|
99
|
+
or f"Failed to execute query for connection '{connection_name}'."
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
rows = _extract_rows(payload.get("data") or payload.get("preview"))
|
|
104
|
+
row_count = (
|
|
105
|
+
payload.get("row_count")
|
|
106
|
+
if isinstance(payload.get("row_count"), int)
|
|
107
|
+
else len(rows)
|
|
108
|
+
)
|
|
109
|
+
resolved_query_file = (
|
|
110
|
+
payload.get("query_file")
|
|
111
|
+
if isinstance(payload.get("query_file"), str)
|
|
112
|
+
else query_file
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return ExecutionResult(
|
|
116
|
+
connection_name=connection_name,
|
|
117
|
+
query_file=resolved_query_file,
|
|
118
|
+
rows=rows,
|
|
119
|
+
row_count=row_count,
|
|
120
|
+
run_id=payload.get("run_id") if isinstance(payload.get("run_id"), str) else None,
|
|
121
|
+
raw_payload=payload,
|
|
122
|
+
raw_command_result=command_result,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse_workspace_shell_connection_list_result(tool_result: Any) -> ConnectionListResult:
|
|
127
|
+
"""Normalize a `workspace_shell` result for `connection list --json`."""
|
|
128
|
+
|
|
129
|
+
command_result = _structured_mapping(tool_result)
|
|
130
|
+
payload = _parse_command_payload(
|
|
131
|
+
command_result,
|
|
132
|
+
empty_output_message="Connection list command produced no output.",
|
|
133
|
+
failure_message="Failed to list connections.",
|
|
134
|
+
)
|
|
135
|
+
if payload.get("success") is False:
|
|
136
|
+
raise ExecutionError(
|
|
137
|
+
(
|
|
138
|
+
payload.get("message")
|
|
139
|
+
or payload.get("error")
|
|
140
|
+
or "Failed to list connections."
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
connections: list[ConnectionSummary] = []
|
|
145
|
+
for item in payload.get("connections") or []:
|
|
146
|
+
if not isinstance(item, dict):
|
|
147
|
+
continue
|
|
148
|
+
name = item.get("name")
|
|
149
|
+
connection_type = item.get("type")
|
|
150
|
+
if not isinstance(name, str) or not isinstance(connection_type, str):
|
|
151
|
+
continue
|
|
152
|
+
capabilities = [
|
|
153
|
+
capability
|
|
154
|
+
for capability in item.get("capabilities") or []
|
|
155
|
+
if isinstance(capability, str)
|
|
156
|
+
]
|
|
157
|
+
display_name = (
|
|
158
|
+
item.get("display_name")
|
|
159
|
+
if isinstance(item.get("display_name"), str)
|
|
160
|
+
else None
|
|
161
|
+
)
|
|
162
|
+
workspace_path = (
|
|
163
|
+
item.get("workspace_path")
|
|
164
|
+
if isinstance(item.get("workspace_path"), str)
|
|
165
|
+
else None
|
|
166
|
+
)
|
|
167
|
+
connections.append(
|
|
168
|
+
ConnectionSummary(
|
|
169
|
+
name=name,
|
|
170
|
+
connection_type=connection_type,
|
|
171
|
+
capabilities=capabilities,
|
|
172
|
+
display_name=display_name,
|
|
173
|
+
workspace_path=workspace_path,
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
count = payload.get("count") if isinstance(payload.get("count"), int) else len(connections)
|
|
178
|
+
message = payload.get("message") if isinstance(payload.get("message"), str) else None
|
|
179
|
+
next_actions = [
|
|
180
|
+
action for action in payload.get("next_actions") or [] if isinstance(action, str)
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
return ConnectionListResult(
|
|
184
|
+
connections=connections,
|
|
185
|
+
count=count,
|
|
186
|
+
message=message,
|
|
187
|
+
next_actions=next_actions,
|
|
188
|
+
raw_payload=payload,
|
|
189
|
+
raw_command_result=command_result,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_rows(value: Any) -> list[dict[str, Any]]:
|
|
194
|
+
if isinstance(value, list):
|
|
195
|
+
return [row for row in value if isinstance(row, dict)]
|
|
196
|
+
if isinstance(value, str) and value:
|
|
197
|
+
parsed = json.loads(value)
|
|
198
|
+
if isinstance(parsed, list):
|
|
199
|
+
return [row for row in parsed if isinstance(row, dict)]
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _structured_mapping(tool_result: Any) -> dict[str, Any]:
|
|
204
|
+
if isinstance(tool_result, dict):
|
|
205
|
+
structured = tool_result.get("structuredContent")
|
|
206
|
+
if isinstance(structured, dict):
|
|
207
|
+
return structured
|
|
208
|
+
return tool_result
|
|
209
|
+
|
|
210
|
+
structured = getattr(tool_result, "structuredContent", None)
|
|
211
|
+
if isinstance(structured, dict):
|
|
212
|
+
return structured
|
|
213
|
+
|
|
214
|
+
if hasattr(tool_result, "model_dump"):
|
|
215
|
+
dumped = tool_result.model_dump(mode="python")
|
|
216
|
+
if isinstance(dumped, dict):
|
|
217
|
+
structured = dumped.get("structuredContent")
|
|
218
|
+
if isinstance(structured, dict):
|
|
219
|
+
return structured
|
|
220
|
+
return dumped
|
|
221
|
+
|
|
222
|
+
raise ExecutionError("Tool returned an unsupported result shape.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_command_payload(
|
|
226
|
+
command_result: dict[str, Any],
|
|
227
|
+
*,
|
|
228
|
+
empty_output_message: str,
|
|
229
|
+
failure_message: str,
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
stdout = (command_result.get("stdout") or "").strip()
|
|
232
|
+
|
|
233
|
+
if not command_result.get("success"):
|
|
234
|
+
raise ExecutionError(
|
|
235
|
+
_command_error_message(command_result, stdout) or failure_message
|
|
236
|
+
)
|
|
237
|
+
if not stdout:
|
|
238
|
+
raise ExecutionError(empty_output_message)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
payload = json.loads(stdout)
|
|
242
|
+
except json.JSONDecodeError as exc:
|
|
243
|
+
raise ExecutionError("Command returned non-JSON stdout.") from exc
|
|
244
|
+
|
|
245
|
+
if not isinstance(payload, dict):
|
|
246
|
+
raise ExecutionError("Command returned a non-object JSON payload.")
|
|
247
|
+
|
|
248
|
+
return payload
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _command_error_message(command_result: dict[str, Any], stdout: str) -> str | None:
|
|
252
|
+
if stdout:
|
|
253
|
+
try:
|
|
254
|
+
payload = json.loads(stdout)
|
|
255
|
+
except json.JSONDecodeError:
|
|
256
|
+
if len(stdout) < 500:
|
|
257
|
+
return stdout
|
|
258
|
+
else:
|
|
259
|
+
if isinstance(payload, dict):
|
|
260
|
+
message = payload.get("message") or payload.get("error")
|
|
261
|
+
if isinstance(message, str) and message:
|
|
262
|
+
return message
|
|
263
|
+
for key in ("message", "error", "stderr"):
|
|
264
|
+
value = command_result.get(key)
|
|
265
|
+
if isinstance(value, str) and value.strip():
|
|
266
|
+
return value.strip()
|
|
267
|
+
return None
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Low-level MCP transport for MarcoPolo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from mcp import ClientSession
|
|
12
|
+
from mcp.client.streamable_http import streamable_http_client
|
|
13
|
+
from mcp.shared._httpx_utils import create_mcp_http_client
|
|
14
|
+
from mcp.types import CallToolResult, ListToolsResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BearerTokenAuth(httpx.Auth):
|
|
18
|
+
"""Attach a bearer token to outgoing HTTP requests."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, token: str) -> None:
|
|
21
|
+
self._token = token
|
|
22
|
+
|
|
23
|
+
def auth_flow(
|
|
24
|
+
self, request: httpx.Request
|
|
25
|
+
) -> Any:
|
|
26
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
27
|
+
yield request
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class MarcoPoloMCPTransport:
|
|
32
|
+
"""Thin wrapper around the official Python MCP SDK."""
|
|
33
|
+
|
|
34
|
+
api_token: str
|
|
35
|
+
server_url: str
|
|
36
|
+
timeout_seconds: float = 30.0
|
|
37
|
+
sse_read_timeout_seconds: float = 300.0
|
|
38
|
+
extra_headers: Mapping[str, str] = field(default_factory=dict)
|
|
39
|
+
session_factory: Callable[..., ClientSession] = ClientSession
|
|
40
|
+
http_client_factory: Callable[..., httpx.AsyncClient] = create_mcp_http_client
|
|
41
|
+
streamable_http_factory: Callable[..., Any] = streamable_http_client
|
|
42
|
+
|
|
43
|
+
@asynccontextmanager
|
|
44
|
+
async def session(self) -> AsyncIterator[ClientSession]:
|
|
45
|
+
"""Open an initialized MCP client session."""
|
|
46
|
+
|
|
47
|
+
headers = dict(self.extra_headers) or None
|
|
48
|
+
auth = BearerTokenAuth(self.api_token)
|
|
49
|
+
timeout = httpx.Timeout(
|
|
50
|
+
self.timeout_seconds,
|
|
51
|
+
read=self.sse_read_timeout_seconds,
|
|
52
|
+
)
|
|
53
|
+
async with self.http_client_factory(
|
|
54
|
+
headers=headers,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
auth=auth,
|
|
57
|
+
) as http_client:
|
|
58
|
+
async with self.streamable_http_factory(
|
|
59
|
+
self.server_url,
|
|
60
|
+
http_client=http_client,
|
|
61
|
+
) as (read_stream, write_stream, _get_session_id):
|
|
62
|
+
async with self.session_factory(read_stream, write_stream) as session:
|
|
63
|
+
await session.initialize()
|
|
64
|
+
yield session
|
|
65
|
+
|
|
66
|
+
async def list_tools(self) -> ListToolsResult:
|
|
67
|
+
"""List tools exposed by the configured MarcoPolo MCP server."""
|
|
68
|
+
|
|
69
|
+
async with self.session() as session:
|
|
70
|
+
return await session.list_tools()
|
|
71
|
+
|
|
72
|
+
async def call_tool(
|
|
73
|
+
self, name: str, arguments: dict[str, Any] | None = None
|
|
74
|
+
) -> CallToolResult:
|
|
75
|
+
"""Call a named MCP tool with optional arguments."""
|
|
76
|
+
|
|
77
|
+
async with self.session() as session:
|
|
78
|
+
return await session.call_tool(name, arguments)
|
|
79
|
+
|
|
80
|
+
async def workspace_shell(
|
|
81
|
+
self, command: str, context: str, timeout: int | None = None
|
|
82
|
+
) -> CallToolResult:
|
|
83
|
+
"""Call MarcoPolo's `workspace_shell` tool."""
|
|
84
|
+
|
|
85
|
+
arguments: dict[str, Any] = {"command": command, "context": context}
|
|
86
|
+
if timeout is not None:
|
|
87
|
+
arguments["timeout"] = timeout
|
|
88
|
+
return await self.call_tool("workspace_shell", arguments)
|
|
89
|
+
|
|
90
|
+
async def data_query(
|
|
91
|
+
self,
|
|
92
|
+
connection_name: str,
|
|
93
|
+
query_file: str,
|
|
94
|
+
context: str,
|
|
95
|
+
max_rows: int | None = None,
|
|
96
|
+
params: dict[str, Any] | None = None,
|
|
97
|
+
) -> CallToolResult:
|
|
98
|
+
"""Call MarcoPolo's `data_query` tool."""
|
|
99
|
+
|
|
100
|
+
arguments: dict[str, Any] = {
|
|
101
|
+
"connection_name": connection_name,
|
|
102
|
+
"query_file": query_file,
|
|
103
|
+
"context": context,
|
|
104
|
+
}
|
|
105
|
+
if max_rows is not None:
|
|
106
|
+
arguments["max_rows"] = max_rows
|
|
107
|
+
if params is not None:
|
|
108
|
+
arguments["params"] = params
|
|
109
|
+
return await self.call_tool("data_query", arguments)
|
marcopolo/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
marcopolo/query_files.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Internal remote query-file authoring utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import Any, Literal, Protocol
|
|
11
|
+
|
|
12
|
+
PayloadFormat = Literal["json", "sql", "text"]
|
|
13
|
+
_VALID_PAYLOAD_FORMATS = {"json", "sql", "text"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SupportsWorkspaceShell(Protocol):
|
|
17
|
+
"""Minimal protocol required for remote query-file authoring."""
|
|
18
|
+
|
|
19
|
+
async def workspace_shell(
|
|
20
|
+
self, command: str, context: str, timeout: int | None = None
|
|
21
|
+
) -> Any:
|
|
22
|
+
"""Run a command in the MarcoPolo workspace."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class PreparedQueryFile:
|
|
27
|
+
"""Locally prepared query-file metadata before remote write."""
|
|
28
|
+
|
|
29
|
+
connection_name: str
|
|
30
|
+
query_file: str
|
|
31
|
+
payload_format: PayloadFormat
|
|
32
|
+
content: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class AuthoredQueryFile:
|
|
37
|
+
"""Remote query-file metadata after workspace persistence."""
|
|
38
|
+
|
|
39
|
+
connection_name: str
|
|
40
|
+
query_file: str
|
|
41
|
+
payload_format: PayloadFormat
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class QueryFileAuthoringError(ValueError):
|
|
45
|
+
"""Raised when payload serialization choices are ambiguous or invalid."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MarcoPoloQueryFileAuthor:
|
|
49
|
+
"""Prepare and persist query files in the remote MarcoPolo workspace."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, transport: SupportsWorkspaceShell) -> None:
|
|
52
|
+
self._transport = transport
|
|
53
|
+
|
|
54
|
+
def prepare(
|
|
55
|
+
self,
|
|
56
|
+
connection_name: str,
|
|
57
|
+
payload: dict[str, Any] | list[Any] | str,
|
|
58
|
+
*,
|
|
59
|
+
query_name: str,
|
|
60
|
+
payload_format: PayloadFormat | None = None,
|
|
61
|
+
) -> PreparedQueryFile:
|
|
62
|
+
"""Prepare content and a workspace-relative query-file path."""
|
|
63
|
+
|
|
64
|
+
slug = _slugify(query_name)
|
|
65
|
+
if isinstance(payload, (dict, list)):
|
|
66
|
+
if payload_format not in (None, "json"):
|
|
67
|
+
raise QueryFileAuthoringError(
|
|
68
|
+
"Structured payloads support only the 'json' payload_format."
|
|
69
|
+
)
|
|
70
|
+
content = json.dumps(payload, indent=2) + "\n"
|
|
71
|
+
resolved_format: PayloadFormat = "json"
|
|
72
|
+
elif isinstance(payload, str):
|
|
73
|
+
if payload_format is None:
|
|
74
|
+
raise QueryFileAuthoringError(
|
|
75
|
+
"Raw string payloads require an explicit payload_format of "
|
|
76
|
+
"'json', 'sql', or 'text'."
|
|
77
|
+
)
|
|
78
|
+
if payload_format not in _VALID_PAYLOAD_FORMATS:
|
|
79
|
+
raise QueryFileAuthoringError(
|
|
80
|
+
"Unsupported payload_format. Use 'json', 'sql', or 'text'."
|
|
81
|
+
)
|
|
82
|
+
content = payload
|
|
83
|
+
resolved_format = payload_format
|
|
84
|
+
else:
|
|
85
|
+
raise QueryFileAuthoringError(
|
|
86
|
+
"Unsupported payload type. Use dict, list, or str."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
query_file = (
|
|
90
|
+
PurePosixPath("connections")
|
|
91
|
+
/ connection_name
|
|
92
|
+
/ "queries"
|
|
93
|
+
/ f"{slug}.{_extension_for(resolved_format)}"
|
|
94
|
+
).as_posix()
|
|
95
|
+
return PreparedQueryFile(
|
|
96
|
+
connection_name=connection_name,
|
|
97
|
+
query_file=query_file,
|
|
98
|
+
payload_format=resolved_format,
|
|
99
|
+
content=content,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def author(
|
|
103
|
+
self,
|
|
104
|
+
connection_name: str,
|
|
105
|
+
payload: dict[str, Any] | list[Any] | str,
|
|
106
|
+
*,
|
|
107
|
+
context: str,
|
|
108
|
+
query_name: str,
|
|
109
|
+
payload_format: PayloadFormat | None = None,
|
|
110
|
+
timeout: int | None = None,
|
|
111
|
+
) -> AuthoredQueryFile:
|
|
112
|
+
"""Write the prepared query file into the remote MarcoPolo workspace."""
|
|
113
|
+
|
|
114
|
+
prepared = self.prepare(
|
|
115
|
+
connection_name,
|
|
116
|
+
payload,
|
|
117
|
+
query_name=query_name,
|
|
118
|
+
payload_format=payload_format,
|
|
119
|
+
)
|
|
120
|
+
await self._transport.workspace_shell(
|
|
121
|
+
command=_build_remote_write_command(prepared),
|
|
122
|
+
context=context,
|
|
123
|
+
timeout=timeout,
|
|
124
|
+
)
|
|
125
|
+
return AuthoredQueryFile(
|
|
126
|
+
connection_name=prepared.connection_name,
|
|
127
|
+
query_file=prepared.query_file,
|
|
128
|
+
payload_format=prepared.payload_format,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _slugify(value: str) -> str:
|
|
133
|
+
"""Create a readable underscore-based slug."""
|
|
134
|
+
|
|
135
|
+
slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower())
|
|
136
|
+
slug = re.sub(r"_+", "_", slug).strip("_")
|
|
137
|
+
if not slug:
|
|
138
|
+
raise QueryFileAuthoringError(
|
|
139
|
+
"query_name must contain at least one alphanumeric character."
|
|
140
|
+
)
|
|
141
|
+
return slug
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _extension_for(payload_format: PayloadFormat) -> str:
|
|
145
|
+
"""Map payload formats to file extensions."""
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"json": "json",
|
|
149
|
+
"sql": "sql",
|
|
150
|
+
"text": "txt",
|
|
151
|
+
}[payload_format]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_remote_write_command(prepared: PreparedQueryFile) -> str:
|
|
155
|
+
"""Build a shell-safe command that writes content in `/workspace`."""
|
|
156
|
+
|
|
157
|
+
encoded = base64.b64encode(prepared.content.encode("utf-8")).decode("ascii")
|
|
158
|
+
return "\n".join(
|
|
159
|
+
[
|
|
160
|
+
"python3 - <<'PY'",
|
|
161
|
+
"from pathlib import Path",
|
|
162
|
+
"import base64",
|
|
163
|
+
f"path = Path('/workspace/{prepared.query_file}')",
|
|
164
|
+
f"content = base64.b64decode('{encoded}')",
|
|
165
|
+
"path.parent.mkdir(parents=True, exist_ok=True)",
|
|
166
|
+
"path.write_bytes(content)",
|
|
167
|
+
"print(path.as_posix())",
|
|
168
|
+
"PY",
|
|
169
|
+
]
|
|
170
|
+
)
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: marcopolo-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for executing MarcoPolo MCP data operations.
|
|
5
|
+
Project-URL: Homepage, https://github.com/immersa-co/marcopolo-python-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/immersa-co/marcopolo-python-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/immersa-co/marcopolo-python-sdk/issues
|
|
8
|
+
Author: Immersa
|
|
9
|
+
License: Proprietary
|
|
10
|
+
Keywords: client,data,marcopolo,mcp
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: mcp<2,>=1.28.1
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build>=1.2.2; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest>=8.3.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff>=0.5.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: twine>=5.1.1; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# MarcoPolo Client
|
|
26
|
+
|
|
27
|
+
Python client library for executing governed MarcoPolo MCP connection
|
|
28
|
+
operations from application code.
|
|
29
|
+
|
|
30
|
+
The initial public surface is intentionally small:
|
|
31
|
+
|
|
32
|
+
- one async metadata API: `list_connections()`
|
|
33
|
+
- one async top-level API: `execute()`
|
|
34
|
+
- one async lower-level helper: `execute_query_file()`
|
|
35
|
+
- internal handling for remote query-file authoring under
|
|
36
|
+
`connections/<connection_name>/queries/`
|
|
37
|
+
|
|
38
|
+
The low-level MCP transport uses the official Python `mcp` SDK.
|
|
39
|
+
|
|
40
|
+
## Status
|
|
41
|
+
|
|
42
|
+
This repository implements the approved first cut of the client:
|
|
43
|
+
|
|
44
|
+
- async connection discovery
|
|
45
|
+
- async-only execution
|
|
46
|
+
- required `context`
|
|
47
|
+
- caller-provided `query_name`
|
|
48
|
+
- syntax-agnostic payload handling
|
|
49
|
+
- canonical execution through `workspace_shell("connection query ... --json")`
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
Install from PyPI:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python3 -m pip install marcopolo-sdk
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Install for local development:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python3 -m pip install -e ".[dev]"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Build Release Artifacts
|
|
66
|
+
|
|
67
|
+
Build and validate the PyPI release artifacts locally:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python3 -m pip install -e ".[dev]"
|
|
71
|
+
python3 -m build
|
|
72
|
+
python3 -m twine check dist/*
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The built source distribution and wheel are written under `dist/`.
|
|
76
|
+
|
|
77
|
+
## Publish to PyPI
|
|
78
|
+
|
|
79
|
+
This repository is set up for GitHub Actions based PyPI trusted publishing.
|
|
80
|
+
|
|
81
|
+
High-level flow:
|
|
82
|
+
|
|
83
|
+
1. Configure the one-time trusted publisher on PyPI for this repository.
|
|
84
|
+
2. Push a version tag such as `v0.1.1`.
|
|
85
|
+
3. Create a GitHub release from that tag.
|
|
86
|
+
4. The `publish-pypi.yml` workflow builds the package and uploads it to PyPI.
|
|
87
|
+
|
|
88
|
+
See [`PYPI_PUBLISHING.md`](PYPI_PUBLISHING.md) for the concrete setup steps.
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
`MarcoPolo` does not read `.env` files or process environment variables.
|
|
93
|
+
Your application owns configuration loading and must pass explicit settings into
|
|
94
|
+
the constructor.
|
|
95
|
+
|
|
96
|
+
## Public API
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import os
|
|
100
|
+
|
|
101
|
+
from marcopolo import MarcoPolo
|
|
102
|
+
|
|
103
|
+
marcopolo = MarcoPolo(
|
|
104
|
+
api_token=os.environ["MARCOPOLO_API_TOKEN"],
|
|
105
|
+
server_url=os.environ["MARCOPOLO_MCP_SERVER_URL"],
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### `list_connections()`
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
async def list_connections(
|
|
113
|
+
*,
|
|
114
|
+
context: str,
|
|
115
|
+
timeout: int | None = None,
|
|
116
|
+
) -> ConnectionListResult
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Behavior:
|
|
120
|
+
|
|
121
|
+
- executes `workspace_shell("connection list --json")`
|
|
122
|
+
- parses the returned shell payload and nested JSON `stdout`
|
|
123
|
+
- returns normalized connection metadata including capabilities
|
|
124
|
+
|
|
125
|
+
### `execute()`
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
async def execute(
|
|
129
|
+
connection_name: str,
|
|
130
|
+
payload: dict | list | str,
|
|
131
|
+
*,
|
|
132
|
+
query_name: str,
|
|
133
|
+
context: str,
|
|
134
|
+
payload_format: Literal["json", "sql", "text"] | None = None,
|
|
135
|
+
params: dict | None = None,
|
|
136
|
+
timeout: int | None = None,
|
|
137
|
+
) -> ExecutionResult
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Behavior:
|
|
141
|
+
|
|
142
|
+
- writes a durable remote query file under
|
|
143
|
+
`connections/<connection_name>/queries/`
|
|
144
|
+
- executes it with `connection query <connection_name> --file <query_file> --json`
|
|
145
|
+
- always requests the full result set by passing `--sample-rows -1` internally
|
|
146
|
+
- normalizes the result into `ExecutionResult`
|
|
147
|
+
|
|
148
|
+
### `execute_query_file()`
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
async def execute_query_file(
|
|
152
|
+
connection_name: str,
|
|
153
|
+
query_file: str,
|
|
154
|
+
*,
|
|
155
|
+
context: str,
|
|
156
|
+
params: dict | None = None,
|
|
157
|
+
timeout: int | None = None,
|
|
158
|
+
) -> ExecutionResult
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Use this when the query file already exists in the MarcoPolo workspace and you
|
|
162
|
+
only want execution.
|
|
163
|
+
|
|
164
|
+
## Payload Rules
|
|
165
|
+
|
|
166
|
+
The client is syntax-agnostic. It does not read connector `SYNTAX.md` files or
|
|
167
|
+
infer connector semantics. The caller is responsible for sending a payload that
|
|
168
|
+
is valid for the target connection.
|
|
169
|
+
|
|
170
|
+
Serialization rules:
|
|
171
|
+
|
|
172
|
+
- `dict` and `list` payloads are serialized as pretty JSON and written as
|
|
173
|
+
`.json`
|
|
174
|
+
- raw `str` payloads require explicit `payload_format`
|
|
175
|
+
- supported raw string formats are `json`, `sql`, and `text`
|
|
176
|
+
- `query_name` is mandatory and is sanitized into a readable underscore-based
|
|
177
|
+
filename
|
|
178
|
+
|
|
179
|
+
Examples:
|
|
180
|
+
|
|
181
|
+
- `query_name="Top 5 Accounts By Revenue"` becomes
|
|
182
|
+
`top_5_accounts_by_revenue.json`
|
|
183
|
+
- `query_name="Loki Errors Last 24h"` becomes
|
|
184
|
+
`loki_errors_last_24h.json`
|
|
185
|
+
|
|
186
|
+
## Usage
|
|
187
|
+
|
|
188
|
+
The client is async-only by design. A simple application entrypoint can use
|
|
189
|
+
`asyncio.run(...)`.
|
|
190
|
+
|
|
191
|
+
### Jira read
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
import asyncio
|
|
195
|
+
import os
|
|
196
|
+
|
|
197
|
+
from marcopolo import MarcoPolo
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def main() -> None:
|
|
201
|
+
marcopolo = MarcoPolo(
|
|
202
|
+
api_token=os.environ["MARCOPOLO_API_TOKEN"],
|
|
203
|
+
server_url=os.environ["MARCOPOLO_MCP_SERVER_URL"],
|
|
204
|
+
)
|
|
205
|
+
connections = await marcopolo.list_connections(
|
|
206
|
+
context="List available governed connections before choosing one.",
|
|
207
|
+
)
|
|
208
|
+
print(connections.count)
|
|
209
|
+
print(connections.connections[:2])
|
|
210
|
+
|
|
211
|
+
result = await marcopolo.execute(
|
|
212
|
+
"jira-jql-20260710-1527",
|
|
213
|
+
{
|
|
214
|
+
"jql": (
|
|
215
|
+
"assignee = currentUser() "
|
|
216
|
+
"AND statusCategory != Done ORDER BY updated DESC"
|
|
217
|
+
),
|
|
218
|
+
"fields": [
|
|
219
|
+
"issuekey",
|
|
220
|
+
"summary",
|
|
221
|
+
"status",
|
|
222
|
+
"priority",
|
|
223
|
+
"project",
|
|
224
|
+
"assignee",
|
|
225
|
+
"created",
|
|
226
|
+
"updated",
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
query_name="open_tickets_current_user",
|
|
230
|
+
context="Load current open Jira tickets for the current Jira user.",
|
|
231
|
+
)
|
|
232
|
+
print(result.row_count)
|
|
233
|
+
print(result.rows[:2])
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
asyncio.run(main())
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Google Drive read
|
|
240
|
+
|
|
241
|
+
Validated live against `google-drive-20260710-1517` and covered by
|
|
242
|
+
`tests/test_integration_reads.py::test_execute_google_drive_sheet_read`.
|
|
243
|
+
For the current connection scope, the working form is the plain display name
|
|
244
|
+
`sales-by-quarter`; a folder-qualified path such as
|
|
245
|
+
`some-folder/sales-by-quarter` does not resolve.
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
result = await marcopolo.execute(
|
|
249
|
+
"google-drive-20260710-1517",
|
|
250
|
+
{
|
|
251
|
+
"file": "sales-by-quarter",
|
|
252
|
+
"sheet": "0",
|
|
253
|
+
},
|
|
254
|
+
query_name="sales_by_quarter_sheet0",
|
|
255
|
+
context="Read the sales-by-quarter spreadsheet from Google Drive.",
|
|
256
|
+
)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Loki read
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
result = await marcopolo.execute(
|
|
263
|
+
"grafana-loki-20260519-2152",
|
|
264
|
+
{
|
|
265
|
+
"operation": "query_range",
|
|
266
|
+
"query": '{job=~".+"} |~ "(?i)error"',
|
|
267
|
+
"start": "now-24h",
|
|
268
|
+
"end": "now",
|
|
269
|
+
"limit": 200,
|
|
270
|
+
"direction": "backward",
|
|
271
|
+
},
|
|
272
|
+
query_name="errors_last_24h",
|
|
273
|
+
context="Read recent error logs from Loki.",
|
|
274
|
+
)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Salesforce update
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
result = await marcopolo.execute(
|
|
281
|
+
"salesforce-demo-3841cee8-20260709-2149",
|
|
282
|
+
{
|
|
283
|
+
"endpoint": "/services/data/v47.0/sobjects/Account/001gK00000DFg5tQAD",
|
|
284
|
+
"method": "PATCH",
|
|
285
|
+
"body": {
|
|
286
|
+
"Description": "Customer since 2024-01-24. Tier: enterprise",
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
query_name="update_existing_account_description",
|
|
290
|
+
context="Apply a non-destructive Salesforce account update.",
|
|
291
|
+
)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Salesforce insert
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
result = await marcopolo.execute(
|
|
298
|
+
"salesforce-demo-3841cee8-20260709-2149",
|
|
299
|
+
{
|
|
300
|
+
"endpoint": "/services/data/v47.0/sobjects/Opportunity",
|
|
301
|
+
"method": "POST",
|
|
302
|
+
"body": {
|
|
303
|
+
"Name": "MarcoPolo Client Example Opportunity",
|
|
304
|
+
"StageName": "Prospecting",
|
|
305
|
+
"CloseDate": "2026-07-31",
|
|
306
|
+
"Description": "Created from the MarcoPolo client README example",
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
query_name="create_example_opportunity",
|
|
310
|
+
context="Create a Salesforce opportunity through the MarcoPolo client.",
|
|
311
|
+
)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Execute an existing remote query file
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
result = await marcopolo.execute_query_file(
|
|
318
|
+
"google-drive-20260710-1517",
|
|
319
|
+
"connections/google-drive-20260710-1517/queries/sales_by_quarter_sheet0.json",
|
|
320
|
+
context="Execute a pre-authored Google Drive query file.",
|
|
321
|
+
)
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## Result Model
|
|
325
|
+
|
|
326
|
+
`execute()` and `execute_query_file()` return `ExecutionResult`:
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
ExecutionResult(
|
|
330
|
+
connection_name: str,
|
|
331
|
+
query_file: str,
|
|
332
|
+
rows: list[dict[str, Any]],
|
|
333
|
+
row_count: int,
|
|
334
|
+
run_id: str | None,
|
|
335
|
+
raw_payload: dict[str, Any],
|
|
336
|
+
raw_command_result: dict[str, Any],
|
|
337
|
+
)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Notes:
|
|
341
|
+
|
|
342
|
+
- `rows` is extracted from `data` or `preview`
|
|
343
|
+
- `row_count` uses the command payload value when present, otherwise `len(rows)`
|
|
344
|
+
- write operations may still return useful rows, such as Salesforce create
|
|
345
|
+
responses with inserted record IDs
|
|
346
|
+
- `raw_payload` and `raw_command_result` are preserved for debugging
|
|
347
|
+
|
|
348
|
+
`list_connections()` returns `ConnectionListResult`:
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
ConnectionListResult(
|
|
352
|
+
connections: list[ConnectionSummary],
|
|
353
|
+
count: int,
|
|
354
|
+
message: str | None,
|
|
355
|
+
next_actions: list[str],
|
|
356
|
+
raw_payload: dict[str, Any],
|
|
357
|
+
raw_command_result: dict[str, Any],
|
|
358
|
+
)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Where each `ConnectionSummary` includes:
|
|
362
|
+
|
|
363
|
+
```python
|
|
364
|
+
ConnectionSummary(
|
|
365
|
+
name: str,
|
|
366
|
+
connection_type: str,
|
|
367
|
+
capabilities: list[str],
|
|
368
|
+
display_name: str | None,
|
|
369
|
+
workspace_path: str | None,
|
|
370
|
+
)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
## Observed Result Shapes
|
|
374
|
+
|
|
375
|
+
The exact row schema is connector-specific. The client does not reshape rows
|
|
376
|
+
beyond extracting them from the command response. These are representative
|
|
377
|
+
live-observed examples from the validated connectors.
|
|
378
|
+
|
|
379
|
+
### Jira rows
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
[
|
|
383
|
+
{
|
|
384
|
+
"key": "IMMERSA-455",
|
|
385
|
+
"summary": "Example issue title",
|
|
386
|
+
"created": "2026-05-11T18:28:05.844+0000",
|
|
387
|
+
"project_key": "IMMERSA",
|
|
388
|
+
"project_name": "Immersa",
|
|
389
|
+
"assignee": "{\"displayName\": \"Example User\", ...}",
|
|
390
|
+
"priority_name": "Medium",
|
|
391
|
+
"updated": "2026-05-11T18:28:07.126+0000",
|
|
392
|
+
"status_name": "To Do",
|
|
393
|
+
},
|
|
394
|
+
...,
|
|
395
|
+
]
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
### Google Drive rows
|
|
399
|
+
|
|
400
|
+
```python
|
|
401
|
+
[
|
|
402
|
+
{
|
|
403
|
+
"customer_id": 1,
|
|
404
|
+
"quarter_end_dt": "03/31/2025",
|
|
405
|
+
"billing_amount_usd": 100,
|
|
406
|
+
},
|
|
407
|
+
...,
|
|
408
|
+
]
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
### Loki rows
|
|
412
|
+
|
|
413
|
+
```python
|
|
414
|
+
[
|
|
415
|
+
{
|
|
416
|
+
"timestamp": "2026-07-10T17:42:31.123456Z",
|
|
417
|
+
"line": "ERROR request failed for job=api",
|
|
418
|
+
"labels": "{\"job\": \"duploservices-prod01/mproxy\", ...}",
|
|
419
|
+
},
|
|
420
|
+
...,
|
|
421
|
+
]
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### Salesforce insert rows
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
[
|
|
428
|
+
{
|
|
429
|
+
"id": "006gK00000KlbtRQAR",
|
|
430
|
+
"success": True,
|
|
431
|
+
"errors": "[]",
|
|
432
|
+
},
|
|
433
|
+
...,
|
|
434
|
+
]
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
### Salesforce update and delete rows
|
|
438
|
+
|
|
439
|
+
For successful update and delete operations, the command payload may report
|
|
440
|
+
`row_count = 1` while `rows` is empty because the connector returned no tabular
|
|
441
|
+
data:
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
result.row_count == 1
|
|
445
|
+
result.rows == []
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Raw command payload
|
|
449
|
+
|
|
450
|
+
`ExecutionResult.raw_payload` is the parsed JSON body returned by
|
|
451
|
+
`connection query --json`. Representative shape:
|
|
452
|
+
|
|
453
|
+
```python
|
|
454
|
+
{
|
|
455
|
+
"success": True,
|
|
456
|
+
"data": "[{\"id\":\"006gK00000KlbtRQAR\",\"success\":true,\"errors\":\"[]\"}]",
|
|
457
|
+
"preview": "[{\"id\":\"006gK00000KlbtRQAR\",\"success\":true,\"errors\":\"[]\"}]",
|
|
458
|
+
"row_count": 1,
|
|
459
|
+
"run_id": "run_123",
|
|
460
|
+
"query_file": (
|
|
461
|
+
"connections/salesforce-demo-3841cee8-20260709-2149/"
|
|
462
|
+
"queries/create_example_opportunity.json"
|
|
463
|
+
),
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
## Development
|
|
468
|
+
|
|
469
|
+
Run lint:
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
ruff check .
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
Run tests:
|
|
476
|
+
|
|
477
|
+
```bash
|
|
478
|
+
pytest -q
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
The test suite is live-only. Before running it, load these variables into your
|
|
482
|
+
shell by whatever mechanism your environment uses:
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
export MARCOPOLO_API_TOKEN=...
|
|
486
|
+
export MARCOPOLO_MCP_SERVER_URL=...
|
|
487
|
+
pytest -q -s
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
## Current Limitations
|
|
491
|
+
|
|
492
|
+
- The client does not inspect connection `SYNTAX.md` files. Payload formation
|
|
493
|
+
is fully caller-owned.
|
|
494
|
+
- The first version exposes execution only. Higher-level `query()` helpers and
|
|
495
|
+
connector-specific convenience wrappers are deferred.
|
|
496
|
+
- Jira create/update is not documented by the currently validated Jira
|
|
497
|
+
connection surface, so the examples stay read-only.
|
|
498
|
+
- The Google Drive spec example using `test1` / `test` is environment-dependent
|
|
499
|
+
and not currently available in the active validated connection scope.
|
|
500
|
+
- For the validated Google Drive spreadsheet example, use the authorized file
|
|
501
|
+
display name `sales-by-quarter` or a file ID/URL. Folder-qualified display
|
|
502
|
+
paths such as `some-folder/sales-by-quarter` are not currently resolved by
|
|
503
|
+
the active connection.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
marcopolo/__init__.py,sha256=5efk9skLW0lnu7Pjj2iEGa4A63a0QhRsKRZFdwVtaNY,700
|
|
2
|
+
marcopolo/_version.py,sha256=AZzdIzOEG4PaKjlDweBMtpvnedcGjSy4Un3qHoLRnDQ,55
|
|
3
|
+
marcopolo/client.py,sha256=wQf_L6cv0Jf6VSiUF9-7qDeEv1rnHI3OB65Rkwx13NA,3572
|
|
4
|
+
marcopolo/execution.py,sha256=H63KaxWy-tBBa44vzGJYrF_AAqbNhyY7usXMX7tPkEc,8151
|
|
5
|
+
marcopolo/mcp_transport.py,sha256=MtdVf-o_WJbqY6TtXrvIVwCYbaqAjDFF0zEhm45G7Cg,3749
|
|
6
|
+
marcopolo/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
7
|
+
marcopolo/query_files.py,sha256=BKb4bz4rmfngHwFjzAwwXKCTkeqnFDEhcH4Rsy4QDH8,5310
|
|
8
|
+
marcopolo_sdk-0.1.1.dist-info/METADATA,sha256=7tpYjhKIeu5QCdjQ9Hf425JTTCUBnTCiCu7m3eyOyt4,12787
|
|
9
|
+
marcopolo_sdk-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
marcopolo_sdk-0.1.1.dist-info/RECORD,,
|