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 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
@@ -0,0 +1,3 @@
1
+ """Package version metadata."""
2
+
3
+ __version__ = "0.1.1"
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
+
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any