ocientmcp 1.2.2__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.
ocientmcp/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Ocient MCP Server - Model Context Protocol integration for Ocient databases
2
+
3
+ This MCP server provides AI agents and tools like Windsurf/Cascade with the ability
4
+ to interact with Ocient databases through the Model Context Protocol.
5
+
6
+ Features:
7
+ - Execute SQL queries and return results in JSON format
8
+ - Manage database connections with DSN strings
9
+ - List tables and schemas
10
+ - Get table metadata and column information
11
+ - Execute queries with parameters
12
+ - Transaction support
13
+
14
+ The server exposes tools that can be called by MCP clients to:
15
+ 1. Connect to Ocient databases
16
+ 2. Execute queries and get structured results
17
+ 3. Explore database schema
18
+ 4. Manage connections
19
+ """
20
+
21
+ from ocientmcp.pkg_version import __commit__, __version__
22
+
23
+ __version_info__ = tuple(int(i) for i in __version__.split(".") if i.isdigit())
24
+
25
+ __all__ = ["__commit__", "__version__", "__version_info__"]
ocientmcp/models.py ADDED
@@ -0,0 +1,20 @@
1
+ import dataclasses
2
+ from typing import Dict, List
3
+
4
+ # Slotted classes expose slot descriptors instead of real default values. Because FastMCP (through Pydantic) cannot serialize these,
5
+ # using slots=True breaks schema generation, so dataclasses must avoid slots when used with FastMCP
6
+
7
+
8
+ @dataclasses.dataclass(frozen=True, kw_only=True)
9
+ class StatementResult:
10
+ message: str | None = None
11
+ """Optional message"""
12
+
13
+ columns: List[str] = dataclasses.field(default_factory=list)
14
+ """List of column names"""
15
+
16
+ rows: List[Dict[str, object]] = dataclasses.field(default_factory=list)
17
+ """List of row data"""
18
+
19
+ row_count: int = 0
20
+ """Number of returned rows"""
@@ -0,0 +1,4 @@
1
+ # ocientmcp version
2
+ #
3
+ __version__ = '1.2.2'
4
+ __commit__: str | None = '0d466e5a70a4e28122676eaee26d431c12f13af6+dirty'
ocientmcp/provider.py ADDED
@@ -0,0 +1,198 @@
1
+ """Provider that dynamically exposes SQL MCP tools from the Ocient database."""
2
+
3
+ import inspect
4
+ import json
5
+ import logging
6
+ import math
7
+ from collections.abc import Sequence
8
+ from types import FunctionType
9
+ from typing import Optional
10
+
11
+ from fastmcp import Context
12
+ from fastmcp.server.providers import Provider
13
+ from fastmcp.tools.base import Tool
14
+ from fastmcp.tools.function_tool import FunctionTool
15
+
16
+ from ocientmcp.models import StatementResult
17
+ from ocientmcp.server import OcientMCPServer, TransportMode
18
+ from ocientmcp.tools import execute_statement
19
+
20
+ LOGGER = logging.getLogger(__name__)
21
+
22
+
23
+ def _escape_identifier(identifier: str) -> str:
24
+ """Escape a SQL identifier by doubling any double quotes."""
25
+ escaped = identifier.replace('"', '""')
26
+ return f'"{escaped}"'
27
+
28
+
29
+ def _escape_string(value: str) -> str:
30
+ """Escape a SQL string by doubling any single quotes."""
31
+ escaped = value.replace("'", "''")
32
+ return f"'{escaped}'"
33
+
34
+
35
+ def _convert_python_to_sql(value: object) -> str:
36
+ """Format a Python value as a SQL literal for use in CALL MCP TOOL."""
37
+ if value is None:
38
+ return "NULL"
39
+ if isinstance(value, bool):
40
+ return "TRUE" if value else "FALSE"
41
+ if isinstance(value, (int, float)):
42
+ if isinstance(value, float) and not math.isfinite(value):
43
+ msg = f"Non-finite float value {value!r} cannot be represented as a SQL literal"
44
+ raise ValueError(msg)
45
+ return str(value)
46
+ if isinstance(value, (dict, list)):
47
+ return _escape_string(json.dumps(value))
48
+ return _escape_string(str(value))
49
+
50
+
51
+ # Maps the base SQL type name to a Python type. Complex types (DATE, UUID, etc.)
52
+ # round-trip cleanly as strings.
53
+ _SQL_TYPE_TO_PYTHON: dict[str, type] = {
54
+ "VARCHAR": str,
55
+ "CHAR": str,
56
+ "CHARACTER": str,
57
+ "TEXT": str,
58
+ "INT": int,
59
+ "INTEGER": int,
60
+ "BIGINT": int,
61
+ "SMALLINT": int,
62
+ "TINYINT": int,
63
+ "BYTE": int,
64
+ "FLOAT": float,
65
+ "REAL": float,
66
+ "DOUBLE": float,
67
+ "DECIMAL": float,
68
+ "NUMERIC": float,
69
+ "BOOLEAN": bool,
70
+ }
71
+
72
+
73
+ def _sql_type_to_python(sql_type: str, nullable: bool) -> object:
74
+ """Return the Python type for an Ocient SQL type string."""
75
+ base = sql_type.split("(")[0].strip().upper()
76
+ py_type: type = _SQL_TYPE_TO_PYTHON.get(base, str)
77
+ return Optional[py_type] if nullable else py_type
78
+
79
+
80
+ def _make_db_tool(
81
+ schema: str,
82
+ name: str,
83
+ description: str,
84
+ arg_names: list[str],
85
+ arg_types: list[str],
86
+ arg_nullability: list[bool],
87
+ ) -> FunctionTool:
88
+ """Create a FunctionTool that dispatches to a database SQL MCP tool.
89
+
90
+ The returned tool has the MCP name ``{schema}__{name}`` and a proper
91
+ parameter schema derived from the tool's argument metadata in
92
+ ``sys.mcp_tools``.
93
+ """
94
+ tool_name = f"{schema}__{name}"
95
+
96
+ # Build the tool function as a **kwargs closure so the body is generic,
97
+ # but give it a custom __signature__ so FastMCP sees the named parameters
98
+ # and generates the correct JSON schema.
99
+ def _closure(s: str, n: str, names: list[str]) -> FunctionType:
100
+ async def tool_fn(ctx: Context, **kwargs: object) -> StatementResult:
101
+ args = [kwargs.get(arg_name) for arg_name in names]
102
+ formatted_args = ", ".join(_convert_python_to_sql(a) for a in args)
103
+ sql = f"CALL MCP TOOL {_escape_identifier(s)}.{_escape_identifier(n)}({formatted_args})"
104
+ return await execute_statement(ctx, sql)
105
+
106
+ return tool_fn # type: ignore[return-value]
107
+
108
+ fn = _closure(schema, name, arg_names)
109
+ fn.__name__ = tool_name
110
+ fn.__qualname__ = tool_name
111
+
112
+ # Construct a signature with proper named parameters so FastMCP derives the
113
+ # right JSON schema. inspect.signature() respects __signature__, which is
114
+ # used by FunctionTool.from_function → ParsedFunction.from_function.
115
+ params: list[inspect.Parameter] = [
116
+ inspect.Parameter("ctx", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Context)
117
+ ]
118
+ annotations: dict[str, object] = {"ctx": Context, "return": StatementResult}
119
+
120
+ for arg_name, sql_type, nullable in zip(arg_names, arg_types, arg_nullability):
121
+ py_type = _sql_type_to_python(sql_type, nullable)
122
+ params.append(
123
+ inspect.Parameter(
124
+ arg_name,
125
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
126
+ annotation=py_type,
127
+ default=None if nullable else inspect.Parameter.empty,
128
+ )
129
+ )
130
+ annotations[arg_name] = py_type
131
+
132
+ fn.__signature__ = inspect.Signature(params, return_annotation=StatementResult) # type: ignore[attr-defined]
133
+ fn.__annotations__ = annotations
134
+
135
+ return FunctionTool.from_function(fn, name=tool_name, description=description)
136
+
137
+
138
+ class OcientMcpToolProvider(Provider):
139
+ """Dynamically exposes SQL MCP tools defined in ``sys.mcp_tools`` as MCP tools.
140
+
141
+ Each database tool is exposed with the name ``{schema}__{name}`` so clients
142
+ can call it directly without going through a separate discovery step.
143
+ """
144
+
145
+ def __init__(self, server: OcientMCPServer) -> None:
146
+ super().__init__()
147
+ self._server = server
148
+
149
+ async def _list_tools(self) -> Sequence[Tool]:
150
+ is_http = self._server.get_transport() == TransportMode.HTTP
151
+ if is_http:
152
+ conn = self._server.connect_for_discovery()
153
+ else:
154
+ conn = self._server.fetch_stdio_connection()
155
+
156
+ if conn is None:
157
+ LOGGER.debug("No connection available; skipping dynamic SQL MCP tool listing")
158
+ return []
159
+
160
+ try:
161
+ with conn.cursor() as cursor:
162
+ cursor.execute(
163
+ "SELECT schema, name, description, "
164
+ "argument_names, argument_types, argument_nullability "
165
+ "FROM sys.mcp_tools ORDER BY schema, name"
166
+ )
167
+ if cursor.description is None:
168
+ return []
169
+ rows = cursor.fetchall()
170
+ except Exception as e:
171
+ LOGGER.warning("Failed to query sys.mcp_tools: %s", e)
172
+ return []
173
+ finally:
174
+ if is_http:
175
+ conn.close()
176
+
177
+ tools: list[Tool] = []
178
+ for row in rows:
179
+ db_schema, db_name, description, arg_names_raw, arg_types_raw, arg_nullability_raw = row
180
+ arg_names = list(arg_names_raw or [])
181
+ arg_types = list(arg_types_raw or [])
182
+ arg_nullability = [bool(x) for x in (arg_nullability_raw or [])]
183
+
184
+ try:
185
+ tool = _make_db_tool(
186
+ db_schema,
187
+ db_name,
188
+ description or "",
189
+ arg_names,
190
+ arg_types,
191
+ arg_nullability,
192
+ )
193
+ tools.append(tool)
194
+ except Exception as e:
195
+ LOGGER.warning("Failed to create tool for %s.%s: %s", db_schema, db_name, e)
196
+
197
+ LOGGER.info("Discovered %d SQL MCP tools from sys.mcp_tools", len(tools))
198
+ return tools
ocientmcp/py.typed ADDED
File without changes
ocientmcp/server.py ADDED
@@ -0,0 +1,344 @@
1
+ import contextlib
2
+ import logging
3
+ import sys
4
+ from collections.abc import Iterator
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ from enum import Enum
8
+ from typing import Any, Callable, TypeVar
9
+
10
+ from fastmcp import Context, FastMCP
11
+ from fastmcp.server.dependencies import get_http_headers
12
+ from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
13
+ from mcp.types import AnyFunction
14
+ from pydantic import BaseModel, ConfigDict, model_validator
15
+
16
+ import pyocient
17
+ from ocientmcp.sso import get_google_id_token
18
+ from ocientmcp.token_cache import clear_token, load_token, save_token
19
+ from pyocient.api import TLSArgType
20
+
21
+ LOGGER = logging.getLogger(__name__)
22
+
23
+ F = TypeVar("F", bound=AnyFunction)
24
+
25
+
26
+ class TransportMode(Enum):
27
+ STDIO = "stdio"
28
+ STDIO_SSO = "stdio_sso"
29
+ HTTP = "http"
30
+
31
+
32
+ @dataclass(frozen=True, kw_only=True, slots=True)
33
+ class SSOConfig:
34
+ """Configuration for SSO-based STDIO connections."""
35
+
36
+ hosts: str
37
+ database: str
38
+ port: int = 4050
39
+ tls: TLSArgType = "unverified"
40
+ identity_provider: str | None = None
41
+
42
+
43
+ class ConnectionCredentials(BaseModel):
44
+ dsn: str
45
+ token_source: str | None = None
46
+
47
+ model_config = ConfigDict(frozen=True)
48
+
49
+ @model_validator(mode="before")
50
+ @classmethod
51
+ def coerce_input(cls, data: object) -> object:
52
+ if isinstance(data, str):
53
+ return {"dsn": data}
54
+ return data
55
+
56
+
57
+ @dataclass
58
+ class HttpSessionInfo:
59
+ connection_credentials: ConnectionCredentials
60
+ connection: pyocient.Connection | None = None
61
+ last_used: datetime | None = None
62
+
63
+
64
+ class OcientMCPServer:
65
+ def __init__(self) -> None:
66
+ self._transport: TransportMode = TransportMode.STDIO
67
+ self._stdio_dsn: str | None = None
68
+ self._stdio_connection: pyocient.Connection | None = None
69
+ self._sso_config: SSOConfig | None = None
70
+ self._http_apiKey_to_connectionCredentials: dict[str, ConnectionCredentials] = {}
71
+ self._http_sessions: dict[str, HttpSessionInfo] = {}
72
+ self._http_connection_ttl = timedelta(minutes=30)
73
+
74
+ self.MCP: FastMCP[Context] | None = None
75
+
76
+ # -----------------------------------------
77
+ # Initialization order:
78
+ # 1. Create MCP
79
+ # 2. Set transport mode (stdio or http)
80
+ # 3. Register Ocient provider
81
+ # 4. Run the MCP
82
+ # -----------------------------------------
83
+
84
+ def initialize_stdio(self, dsn: str | None) -> None:
85
+ self._transport = TransportMode.STDIO
86
+ self._stdio_dsn = dsn
87
+
88
+ def initialize_stdio_sso(self, sso_config: SSOConfig) -> None:
89
+ self._transport = TransportMode.STDIO_SSO
90
+ self._sso_config = sso_config
91
+
92
+ class HttpMiddleware(Middleware):
93
+ def __init__(self, server: "OcientMCPServer"):
94
+ super().__init__()
95
+ self.server = server
96
+
97
+ async def on_request(self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any]) -> Any: # type: ignore [explicit-any]
98
+ # Close any idle connections based on TTL
99
+ now = datetime.now(timezone.utc)
100
+ for session_id, session in self.server._http_sessions.items():
101
+ if session.connection is not None:
102
+ assert session.last_used is not None
103
+ if now - session.last_used > self.server._http_connection_ttl:
104
+ try:
105
+ idle_time = now - session.last_used
106
+ LOGGER.info(f"Closing idle connection for session {session_id} (idle for: {idle_time})")
107
+ session.connection.close()
108
+ except Exception as e:
109
+ LOGGER.warning(f"Failed to close idle connection for session {session_id}: {e}")
110
+ session.connection = None
111
+
112
+ # Use the request header to see if we have an api key
113
+ headers = get_http_headers(include_all=True)
114
+ auth = headers.get("Authorization") or headers.get("authorization")
115
+
116
+ # Use the session id to see if we have an existing session
117
+ fastmcp_context = context.fastmcp_context
118
+ if fastmcp_context is None:
119
+ LOGGER.error("HTTP request missing request context")
120
+ raise ValueError("HTTP request missing request context")
121
+
122
+ # https://gofastmcp.com/servers/context#request-context-availability
123
+ if fastmcp_context.request_context is None:
124
+ LOGGER.debug("Session has not been established yet, doing nothing")
125
+ return await call_next(context)
126
+
127
+ session_id = fastmcp_context.session_id
128
+ if not session_id:
129
+ LOGGER.error("HTTP request missing session ID")
130
+ raise ValueError("HTTP request missing session ID")
131
+
132
+ session_info = self.server._http_sessions.get(session_id)
133
+
134
+ if session_info is None:
135
+ if auth is None:
136
+ # If we have no auth header and no existing session, deny access
137
+ LOGGER.warning("Unauthorized: No auth header and no existing session")
138
+ raise PermissionError("Unauthorized: No auth header and no existing session")
139
+ else:
140
+ # If we have an auth header but no existing session, create a new session
141
+ if not auth.startswith("Bearer "):
142
+ LOGGER.warning("Unauthorized: Invalid auth header")
143
+ raise PermissionError("Unauthorized: Invalid auth header")
144
+
145
+ api_key = auth.removeprefix("Bearer ")
146
+
147
+ connection_credentials = self.server._http_apiKey_to_connectionCredentials.get(api_key)
148
+ if connection_credentials is None:
149
+ LOGGER.warning("Unauthorized: Invalid API key")
150
+ raise PermissionError("Unauthorized: Invalid API key")
151
+
152
+ session_info = HttpSessionInfo(connection_credentials=connection_credentials)
153
+ self.server._http_sessions[session_id] = session_info
154
+
155
+ await fastmcp_context.set_state("session_id", session_id)
156
+ return await call_next(context)
157
+
158
+ def initialize_http(self, auths: dict[str, ConnectionCredentials]) -> None:
159
+ self._transport = TransportMode.HTTP
160
+ self._http_apiKey_to_connectionCredentials = auths
161
+
162
+ if self.MCP is None:
163
+ raise RuntimeError("MCP must be initialized before running HTTP server.")
164
+
165
+ self.MCP.add_middleware(OcientMCPServer.HttpMiddleware(server=self))
166
+
167
+ def tool(self) -> Callable[[F], F]:
168
+ m = self.MCP
169
+ if m is None:
170
+ raise RuntimeError("MCP must be initialized before registering tools.")
171
+
172
+ fastmcp_decorator = m.tool()
173
+
174
+ def wrapping_decorator(fn: F) -> F:
175
+ fastmcp_decorator(fn)
176
+ return fn
177
+
178
+ return wrapping_decorator
179
+
180
+ # -----------------------------------------
181
+ # Connections
182
+ # -----------------------------------------
183
+
184
+ @staticmethod
185
+ @contextlib.contextmanager
186
+ def _protect_stdout() -> Iterator[None]:
187
+ """Redirect stdout to stderr during SSO flow.
188
+
189
+ MCP STDIO transport uses stdout as the protocol channel. Any stray
190
+ print() from pyocient's SSO flow (e.g. device-flow prompts, browser
191
+ failure warnings) would corrupt the MCP protocol stream.
192
+ """
193
+ original = sys.stdout
194
+ sys.stdout = sys.stderr
195
+ try:
196
+ yield
197
+ finally:
198
+ sys.stdout = original
199
+
200
+ def get_transport(self) -> TransportMode:
201
+ return self._transport
202
+
203
+ def _credentials_from_request_headers(self) -> ConnectionCredentials | None:
204
+ """Return the ConnectionCredentials for the API key in the current request headers, or None."""
205
+ headers = get_http_headers(include_all=True)
206
+ auth = headers.get("Authorization") or headers.get("authorization")
207
+ if not auth or not auth.startswith("Bearer "):
208
+ return None
209
+ api_key = auth.removeprefix("Bearer ")
210
+ return self._http_apiKey_to_connectionCredentials.get(api_key)
211
+
212
+ def connect_for_discovery(self) -> pyocient.Connection | None:
213
+ """Open a fresh connection for HTTP tool discovery using the request's API key.
214
+
215
+ Looks up credentials from the Authorization header so that discovery
216
+ always uses the calling user's credentials rather than an arbitrary
217
+ shared credential.
218
+ """
219
+ creds = self._credentials_from_request_headers()
220
+ if creds is None:
221
+ LOGGER.warning("No valid credentials in request headers; cannot establish discovery connection")
222
+ return None
223
+ try:
224
+ return self._connect_with_credentials(creds)
225
+ except Exception as e:
226
+ LOGGER.warning("Failed to establish discovery connection: %s", e)
227
+ return None
228
+
229
+ def _connect_with_credentials(self, creds: ConnectionCredentials) -> pyocient.Connection:
230
+ """Open a pyocient connection using the given credentials.
231
+
232
+ Handles both plain DSN connections and Google ID token auth so that all
233
+ pyocient.connect() calls go through a single credential management path.
234
+ """
235
+ if creds.token_source is not None:
236
+ return pyocient.connect(dsn=creds.dsn, user="id_token", password=get_google_id_token(creds.token_source))
237
+ return pyocient.connect(dsn=creds.dsn)
238
+
239
+ def fetch_stdio_connection(self) -> pyocient.Connection | None:
240
+ """One global connection for STDIO mode (DSN-based)"""
241
+
242
+ if self._stdio_connection:
243
+ return self._stdio_connection
244
+
245
+ try:
246
+ self._stdio_connection = pyocient.connect(self._stdio_dsn)
247
+ return self._stdio_connection
248
+ except Exception as e:
249
+ LOGGER.error(f"Failed to connect (stdio): {e}")
250
+ return None
251
+
252
+ @staticmethod
253
+ def _build_sso_dsn(cfg: SSOConfig) -> str:
254
+ """Build a DSN string for SSO connections.
255
+
256
+ pyocient parses identity_provider and handshake from the DSN query
257
+ string, so we construct a DSN rather than passing keyword args.
258
+ """
259
+ params = f"tls={cfg.tls}&handshake=sso"
260
+ if cfg.identity_provider:
261
+ params += f"&identityprovider={cfg.identity_provider}"
262
+ return f"ocient://@{cfg.hosts}:{cfg.port}/{cfg.database}?{params}"
263
+
264
+ def fetch_stdio_sso_connection(self) -> pyocient.Connection | None:
265
+ """SSO-based STDIO connection with token caching."""
266
+
267
+ if self._stdio_connection:
268
+ return self._stdio_connection
269
+
270
+ assert self._sso_config is not None
271
+ cfg = self._sso_config
272
+
273
+ # Try cached security token first
274
+ cached_token = load_token(cfg.hosts, cfg.database, cfg.port)
275
+ if cached_token is not None:
276
+ try:
277
+ LOGGER.info("Attempting connection with cached security token")
278
+ conn = pyocient.connect(
279
+ host=f"{cfg.hosts}:{cfg.port}",
280
+ database=cfg.database,
281
+ tls=cfg.tls,
282
+ security_token=cached_token,
283
+ )
284
+ # Update cache with the (possibly refreshed) token from the server
285
+ if conn.security_token is not None:
286
+ save_token(cfg.hosts, cfg.database, cfg.port, conn.security_token)
287
+ self._stdio_connection = conn
288
+ LOGGER.info("Connected using cached security token")
289
+ return conn
290
+ except Exception as e:
291
+ LOGGER.info("Cached token failed (%s), falling back to SSO flow", e)
292
+ clear_token(cfg.hosts, cfg.database, cfg.port)
293
+
294
+ # Full SSO browser flow — protect stdout from stray prints
295
+ try:
296
+ dsn = self._build_sso_dsn(cfg)
297
+ LOGGER.info("Starting SSO authentication for %s:%d/%s", cfg.hosts, cfg.port, cfg.database)
298
+ with self._protect_stdout():
299
+ conn = pyocient.connect(dsn)
300
+
301
+ # Cache the security token for future connections
302
+ if conn.security_token is not None:
303
+ save_token(cfg.hosts, cfg.database, cfg.port, conn.security_token)
304
+
305
+ self._stdio_connection = conn
306
+ LOGGER.info("Connected via SSO")
307
+ return conn
308
+ except Exception as e:
309
+ LOGGER.error("SSO connection failed: %s", e)
310
+ return None
311
+
312
+ async def fetch_http_connection(self, ctx: Context) -> pyocient.Connection | None:
313
+ """One connection per API key, managed by HttpMiddleware"""
314
+
315
+ session_id = await ctx.get_state("session_id")
316
+ if session_id is None:
317
+ raise ValueError("Session ID not found in context")
318
+
319
+ session_info = self._http_sessions.get(session_id)
320
+ assert session_info is not None
321
+
322
+ if session_info.connection is None:
323
+ LOGGER.debug("Detected unopened or closed connection, (re)connecting")
324
+ try:
325
+ session_info.connection = self._connect_with_credentials(session_info.connection_credentials)
326
+ session_info.last_used = datetime.now(timezone.utc)
327
+ except Exception as e:
328
+ LOGGER.error(f"Failed to reconnect to Ocient database: {e}")
329
+ raise Exception("Failed to reconnect to Ocient database")
330
+ else:
331
+ # If connection is open, just update last used time
332
+ session_info.last_used = datetime.now(timezone.utc)
333
+
334
+ return session_info.connection
335
+
336
+ async def get_active_connection(self, ctx: Context) -> pyocient.Connection | None:
337
+ if self.get_transport() == TransportMode.HTTP:
338
+ return await self.fetch_http_connection(ctx)
339
+ if self.get_transport() == TransportMode.STDIO_SSO:
340
+ return self.fetch_stdio_sso_connection()
341
+ return self.fetch_stdio_connection()
342
+
343
+
344
+ OCIENTMCP = OcientMCPServer()
@@ -0,0 +1,55 @@
1
+ import argparse
2
+ import logging
3
+
4
+ from ocientmcp.transports import run_http
5
+
6
+
7
+ def main() -> None:
8
+ parser = argparse.ArgumentParser()
9
+ parser.add_argument(
10
+ "--verbose",
11
+ "-v",
12
+ action="store_true",
13
+ help="Enable verbose logging",
14
+ default=False,
15
+ )
16
+ parser.add_argument(
17
+ "--auth-file",
18
+ "-a",
19
+ default="/var/opt/ocient/mcp/auth.yaml",
20
+ help="Path to the secure authentication file",
21
+ )
22
+ parser.add_argument(
23
+ "--host",
24
+ default="127.0.0.1",
25
+ help="Address to bind the HTTP server to. Use 0.0.0.0 to accept connections on every interface",
26
+ )
27
+ parser.add_argument(
28
+ "--port",
29
+ "-p",
30
+ type=int,
31
+ default=8000,
32
+ help="Port to run the HTTP server on",
33
+ )
34
+
35
+ parser.add_argument(
36
+ "--tls-cert",
37
+ help="Path to a PEM-encoded certificate chain. Serves HTTPS instead of HTTP when supplied",
38
+ )
39
+ parser.add_argument(
40
+ "--tls-key",
41
+ help="Path to the PEM-encoded private key for --tls-cert, if the key is not in the certificate file",
42
+ )
43
+
44
+ args = parser.parse_args()
45
+
46
+ if args.tls_key and not args.tls_cert:
47
+ parser.error("--tls-key requires --tls-cert")
48
+
49
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
50
+
51
+ run_http(args.auth_file, args.host, args.port, args.tls_cert, args.tls_key)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,70 @@
1
+ import argparse
2
+ import logging
3
+ import os
4
+
5
+ import httpx
6
+ from fastmcp import FastMCP
7
+ from fastmcp.client.transports import StreamableHttpTransport
8
+ from fastmcp.server.proxy import ProxyClient
9
+ from mcp.shared._httpx_utils import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT
10
+
11
+ LOGGER = logging.getLogger(__name__)
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument(
17
+ "--verbose",
18
+ "-v",
19
+ action="store_true",
20
+ help="Enable verbose logging",
21
+ default=False,
22
+ )
23
+ parser.add_argument("--url", required=True)
24
+ args = parser.parse_args()
25
+
26
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
27
+
28
+ api_key = os.environ.get("OCIENT_MCP_API_KEY")
29
+ if not api_key:
30
+ raise RuntimeError("OCIENT_MCP_API_KEY must be set")
31
+
32
+ # Normalize url to /mcp
33
+ url = args.url.rstrip("/")
34
+ if not url.endswith("/mcp"):
35
+ url = url + "/mcp"
36
+
37
+ LOGGER.info(f"Proxy connecting to MCP endpoint: {url}")
38
+
39
+ # StreamableHttpTransport creates an httpx.AsyncClient with no explicit timeout,
40
+ # inheriting httpx's default of 5 seconds. The MCP server's SSE keepalive pings
41
+ # are sent every 15 seconds, so the client times out before receiving any data
42
+ # during long-running operations. Use MCP-recommended timeouts instead.
43
+ def _create_http_client(
44
+ headers: dict[str, str] | None = None,
45
+ timeout: httpx.Timeout | None = None,
46
+ auth: httpx.Auth | None = None,
47
+ **_extra: object,
48
+ ) -> httpx.AsyncClient:
49
+ if timeout is None:
50
+ timeout = httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
51
+ return httpx.AsyncClient(
52
+ headers=headers,
53
+ timeout=timeout,
54
+ auth=auth,
55
+ follow_redirects=True,
56
+ )
57
+
58
+ transport = StreamableHttpTransport(
59
+ url=url,
60
+ headers={"Authorization": f"Bearer {api_key}"},
61
+ httpx_client_factory=_create_http_client,
62
+ )
63
+
64
+ proxy = FastMCP.as_proxy(ProxyClient(transport), name="Proxy Http Server")
65
+
66
+ proxy.run(transport="stdio", show_banner=False)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,90 @@
1
+ import argparse
2
+ import logging
3
+ import os
4
+ from typing import get_args
5
+
6
+ from ocientmcp.server import SSOConfig
7
+ from ocientmcp.transports import run_stdio, run_stdio_sso
8
+ from pyocient.api import TLSArgType
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser(description="Ocient MCP Server (stdio)")
13
+ parser.add_argument(
14
+ "--verbose",
15
+ "-v",
16
+ action="store_true",
17
+ help="Enable verbose logging",
18
+ default=False,
19
+ )
20
+ parser.add_argument(
21
+ "--hosts",
22
+ help="Comma-separated list of Ocient hosts (e.g. host1.ocient.com,host2.ocient.com). "
23
+ "Can also be set via OCIENT_HOSTS env var.",
24
+ )
25
+ parser.add_argument(
26
+ "--database",
27
+ help="Ocient database name. Can also be set via OCIENT_DATABASE env var.",
28
+ )
29
+ parser.add_argument(
30
+ "--port",
31
+ type=int,
32
+ default=None,
33
+ help="Ocient port (default: 4050). Can also be set via OCIENT_PORT env var.",
34
+ )
35
+ parser.add_argument(
36
+ "--tls",
37
+ default=None,
38
+ choices=["unverified", "on"],
39
+ help="TLS mode (default: unverified). Can also be set via OCIENT_TLS env var.",
40
+ )
41
+ parser.add_argument(
42
+ "--identity-provider",
43
+ default=None,
44
+ help="SSO identity provider name (e.g. 'okta', 'google'). "
45
+ "Can also be set via OCIENT_IDENTITY_PROVIDER env var.",
46
+ )
47
+ args = parser.parse_args()
48
+
49
+ logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO)
50
+
51
+ dsn = os.environ.get("OCIENT_DSN")
52
+
53
+ if dsn:
54
+ # Legacy mode: full DSN with embedded credentials
55
+ run_stdio(dsn)
56
+ return
57
+
58
+ # SSO mode: hosts + database, credentials via browser SSO
59
+ hosts = args.hosts or os.environ.get("OCIENT_HOSTS")
60
+ database = args.database or os.environ.get("OCIENT_DATABASE")
61
+
62
+ if not hosts or not database:
63
+ raise RuntimeError(
64
+ "Either OCIENT_DSN must be set, or both OCIENT_HOSTS and OCIENT_DATABASE "
65
+ "(via env vars or --hosts/--database flags)"
66
+ )
67
+
68
+ port = args.port if args.port is not None else int(os.environ.get("OCIENT_PORT", "4050"))
69
+ tls_raw = args.tls if args.tls is not None else os.environ.get("OCIENT_TLS", "unverified")
70
+ valid_tls_values = get_args(TLSArgType)
71
+ if tls_raw not in valid_tls_values:
72
+ raise RuntimeError(f"Invalid TLS mode '{tls_raw}'. Must be one of: {', '.join(valid_tls_values)}")
73
+ tls: TLSArgType = tls_raw # type: ignore[assignment]
74
+ identity_provider = (
75
+ args.identity_provider if args.identity_provider is not None else os.environ.get("OCIENT_IDENTITY_PROVIDER")
76
+ )
77
+
78
+ sso_config = SSOConfig(
79
+ hosts=hosts,
80
+ database=database,
81
+ port=port,
82
+ tls=tls,
83
+ identity_provider=identity_provider,
84
+ )
85
+
86
+ run_stdio_sso(sso_config)
87
+
88
+
89
+ if __name__ == "__main__":
90
+ main()
ocientmcp/sso.py ADDED
@@ -0,0 +1,16 @@
1
+ import requests # required by google.auth.transport.requests.Request
2
+ from google.auth.transport.requests import Request
3
+ from google.oauth2 import service_account
4
+
5
+
6
+ def get_google_id_token(service_account_file: str) -> str:
7
+ creds = service_account.IDTokenCredentials.from_service_account_file( # type:ignore [no-untyped-call]
8
+ service_account_file,
9
+ target_audience="https://accounts.google.com",
10
+ )
11
+
12
+ if not creds.token or creds.expired:
13
+ creds.refresh(Request())
14
+
15
+ assert isinstance(creds.token, str)
16
+ return creds.token
@@ -0,0 +1,106 @@
1
+ """Local token cache for SSO security tokens.
2
+
3
+ Caches pyocient SecurityTokens to disk so users don't need to re-authenticate
4
+ through the browser on every MCP server restart. Tokens are stored in
5
+ $XDG_CACHE_HOME/ocientmcp/mcp-token-cache.json (or ~/.cache/ocientmcp/) with 0600
6
+ permissions on POSIX systems.
7
+ """
8
+
9
+ import hashlib
10
+ import json
11
+ import logging
12
+ import os
13
+ import stat
14
+ from dataclasses import asdict
15
+ from pathlib import Path
16
+ from typing import cast
17
+
18
+ from pyocient.api import SecurityToken
19
+
20
+ LOGGER = logging.getLogger(__name__)
21
+
22
+ if xdg_cache_home := os.getenv("XDG_CACHE_HOME"):
23
+ _HOME_CACHE = Path(xdg_cache_home)
24
+ else:
25
+ _HOME_CACHE = Path.home() / ".cache"
26
+ _CACHE_DIR = _HOME_CACHE / "ocientmcp"
27
+ _CACHE_FILE = _CACHE_DIR / "mcp-token-cache.json"
28
+
29
+
30
+ def _cache_key(hosts: str, database: str, port: int) -> str:
31
+ """Deterministic key for a given cluster + database."""
32
+ raw = f"{hosts}:{port}/{database}"
33
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
34
+
35
+
36
+ def _ensure_cache_dir() -> None:
37
+ _CACHE_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
38
+
39
+
40
+ def _read_cache() -> dict[str, object]:
41
+ if not _CACHE_FILE.exists():
42
+ return {}
43
+
44
+ # Validate permissions before reading (POSIX only — Windows uses ACLs)
45
+ if os.name == "posix":
46
+ st = _CACHE_FILE.stat()
47
+ mode = stat.S_IMODE(st.st_mode)
48
+ if mode != 0o600:
49
+ LOGGER.warning("Token cache file has insecure permissions %s, ignoring", oct(mode))
50
+ return {}
51
+
52
+ try:
53
+ with open(_CACHE_FILE, "r") as f:
54
+ return cast(dict[str, object], json.load(f))
55
+ except (json.JSONDecodeError, OSError) as e:
56
+ LOGGER.warning("Failed to read token cache: %s", e)
57
+ return {}
58
+
59
+
60
+ def _write_cache(data: dict[str, object]) -> None:
61
+ _ensure_cache_dir()
62
+
63
+ # Write atomically via temp file
64
+ tmp = _CACHE_FILE.with_suffix(".tmp")
65
+ try:
66
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
67
+ with os.fdopen(fd, "w") as f:
68
+ json.dump(data, f, indent=2)
69
+ tmp.rename(_CACHE_FILE)
70
+ except OSError as e:
71
+ LOGGER.warning("Failed to write token cache: %s", e)
72
+ tmp.unlink(missing_ok=True)
73
+
74
+
75
+ def load_token(hosts: str, database: str, port: int) -> SecurityToken | None:
76
+ """Load a cached SecurityToken for the given cluster, or None if not found."""
77
+ cache = _read_cache()
78
+ key = _cache_key(hosts, database, port)
79
+ entry = cache.get(key)
80
+ if entry is None:
81
+ return None
82
+
83
+ try:
84
+ return SecurityToken.from_json(entry) # type: ignore[arg-type]
85
+ except (KeyError, TypeError) as e:
86
+ LOGGER.warning("Corrupt cache entry for key %s: %s", key, e)
87
+ return None
88
+
89
+
90
+ def save_token(hosts: str, database: str, port: int, token: SecurityToken) -> None:
91
+ """Persist a SecurityToken to the local cache."""
92
+ cache = _read_cache()
93
+ key = _cache_key(hosts, database, port)
94
+ cache[key] = asdict(token)
95
+ _write_cache(cache)
96
+ LOGGER.info("Cached security token for %s:%d/%s", hosts, port, database)
97
+
98
+
99
+ def clear_token(hosts: str, database: str, port: int) -> None:
100
+ """Remove a cached token (e.g. after it fails to authenticate)."""
101
+ cache = _read_cache()
102
+ key = _cache_key(hosts, database, port)
103
+ if key in cache:
104
+ del cache[key]
105
+ _write_cache(cache)
106
+ LOGGER.info("Cleared cached token for %s:%d/%s", hosts, port, database)
ocientmcp/tools.py ADDED
@@ -0,0 +1,59 @@
1
+ import logging
2
+ from typing import Union
3
+
4
+ from fastmcp import Context
5
+ from fastmcp.exceptions import ToolError
6
+
7
+ from ocientmcp.models import StatementResult
8
+ from ocientmcp.server import OCIENTMCP
9
+ from ocientmcp.utils import serialize_result
10
+
11
+ LOGGER = logging.getLogger(__name__)
12
+
13
+
14
+ @OCIENTMCP.tool()
15
+ async def execute_statement(
16
+ ctx: Context,
17
+ statement: str,
18
+ parameters: Union[dict[str, object], tuple[object, ...]] | None = None,
19
+ ) -> StatementResult:
20
+ """Executes an arbitrary SQL query or command and returns the result set or
21
+ row count. Use this only when no specific tool (visible in tools/list)
22
+ covers the request."""
23
+ if not statement or not statement.strip():
24
+ raise ToolError("Statement must not be empty.")
25
+
26
+ conn = await OCIENTMCP.get_active_connection(ctx)
27
+ if not conn:
28
+ raise ToolError("No active connection.")
29
+
30
+ try:
31
+ with conn.cursor() as cursor:
32
+ if parameters:
33
+ cursor.execute(statement, parameters)
34
+ else:
35
+ cursor.execute(statement)
36
+
37
+ # Handle DDL/INSERT statements with no result set
38
+ if cursor.description is None:
39
+ return StatementResult(
40
+ message="Statement executed.",
41
+ row_count=cursor.rowcount,
42
+ columns=[],
43
+ rows=[],
44
+ )
45
+
46
+ columns = [desc[0] for desc in cursor.description]
47
+ rows_raw = cursor.fetchall()
48
+
49
+ rows = [{col: serialize_result(val) for col, val in zip(columns, row)} for row in rows_raw]
50
+
51
+ return StatementResult(
52
+ columns=columns,
53
+ rows=rows,
54
+ row_count=len(rows),
55
+ )
56
+
57
+ except Exception as e:
58
+ LOGGER.error("Statement failed: %s", e)
59
+ raise ToolError(f"Statement failed: {e}") from e
@@ -0,0 +1,127 @@
1
+ import logging
2
+ import os
3
+ import stat
4
+
5
+ import yaml
6
+ from fastmcp import FastMCP
7
+ from pydantic import TypeAdapter
8
+
9
+ from ocientmcp.server import OCIENTMCP, ConnectionCredentials, SSOConfig
10
+
11
+ LOGGER = logging.getLogger(__name__)
12
+
13
+
14
+ def run_stdio(dsn: str | None = None) -> None:
15
+ LOGGER.info("Starting Ocient MCP server (stdio mode)...")
16
+
17
+ OCIENTMCP.MCP = FastMCP(name="Ocient")
18
+ OCIENTMCP.initialize_stdio(dsn)
19
+
20
+ # Import decorators/provider after OcientMCP is initialized
21
+ import ocientmcp.tools
22
+ from ocientmcp.provider import OcientMcpToolProvider
23
+
24
+ OCIENTMCP.MCP.add_provider(OcientMcpToolProvider(OCIENTMCP))
25
+
26
+ OCIENTMCP.MCP.run(transport="stdio", show_banner=False)
27
+
28
+
29
+ def run_stdio_sso(sso_config: SSOConfig) -> None:
30
+ LOGGER.info("Starting Ocient MCP server (stdio SSO mode)...")
31
+
32
+ OCIENTMCP.MCP = FastMCP(name="Ocient")
33
+ OCIENTMCP.initialize_stdio_sso(sso_config)
34
+
35
+ # Import decorators/provider after OcientMCP is initialized
36
+ import ocientmcp.tools
37
+ from ocientmcp.provider import OcientMcpToolProvider
38
+
39
+ OCIENTMCP.MCP.add_provider(OcientMcpToolProvider(OCIENTMCP))
40
+
41
+ OCIENTMCP.MCP.run(transport="stdio", show_banner=False)
42
+
43
+
44
+ def _validate_auth_file_permissions(path: str) -> None:
45
+ """Sanity checks to ensure the auth file is secure."""
46
+
47
+ if os.path.islink(path):
48
+ raise PermissionError(f"Auth file '{path}' must not be a symlink")
49
+
50
+ st = os.stat(path)
51
+
52
+ if st.st_uid != os.getuid():
53
+ raise PermissionError(f"Auth file '{path}' must be owned by UID {os.getuid()}, but is owned by UID {st.st_uid}")
54
+
55
+ mode = stat.S_IMODE(st.st_mode)
56
+ if mode != 0o600:
57
+ raise PermissionError(f"Auth file '{path}' must have permissions 600 (rw-------), but has {oct(mode)}")
58
+
59
+
60
+ def _tls_uvicorn_config(tls_cert: str | None, tls_key: str | None) -> dict[str, str]:
61
+ """Builds the uvicorn TLS settings, or an empty mapping to serve plain HTTP."""
62
+
63
+ if tls_cert is None:
64
+ if tls_key is not None:
65
+ msg = "A TLS key was supplied without a TLS certificate"
66
+ raise ValueError(msg)
67
+ return {}
68
+
69
+ config = {"ssl_certfile": tls_cert}
70
+ if tls_key is not None:
71
+ config["ssl_keyfile"] = tls_key
72
+
73
+ for path in config.values():
74
+ if not os.path.isfile(path):
75
+ msg = f"TLS file '{path}' does not exist"
76
+ raise FileNotFoundError(msg)
77
+
78
+ # The key is as sensitive as the auth file, but unlike the auth file it is
79
+ # managed by external certificate tooling, so warn rather than refuse.
80
+ key_holder = config.get("ssl_keyfile", config["ssl_certfile"])
81
+ mode = stat.S_IMODE(os.stat(key_holder).st_mode)
82
+ if mode & 0o077:
83
+ LOGGER.warning(
84
+ "TLS key file '%s' has permissions %s and is readable beyond its owner; consider 600 (rw-------)",
85
+ key_holder,
86
+ oct(mode),
87
+ )
88
+
89
+ return config
90
+
91
+
92
+ def run_http(
93
+ auth_file: str,
94
+ host: str,
95
+ port: int,
96
+ tls_cert: str | None = None,
97
+ tls_key: str | None = None,
98
+ ) -> None:
99
+ LOGGER.info("Starting Ocient MCP server (streamable http mode)...")
100
+
101
+ uvicorn_config = _tls_uvicorn_config(tls_cert, tls_key)
102
+
103
+ # Load an auth file that should map API_KEY -> DSN
104
+ _validate_auth_file_permissions(auth_file)
105
+
106
+ with open(auth_file, "r") as f:
107
+ raw_auths = yaml.safe_load(f)
108
+
109
+ adapter = TypeAdapter(dict[str, ConnectionCredentials])
110
+ auths = adapter.validate_python(raw_auths)
111
+
112
+ OCIENTMCP.MCP = FastMCP(name="Ocient")
113
+ OCIENTMCP.initialize_http(auths)
114
+
115
+ # Import decorators/provider after OcientMCP is initialized
116
+ import ocientmcp.tools
117
+ from ocientmcp.provider import OcientMcpToolProvider
118
+
119
+ OCIENTMCP.MCP.add_provider(OcientMcpToolProvider(OCIENTMCP))
120
+
121
+ OCIENTMCP.MCP.run(
122
+ transport="streamable-http",
123
+ show_banner=False,
124
+ host=host,
125
+ port=port,
126
+ uvicorn_config=uvicorn_config,
127
+ )
ocientmcp/utils.py ADDED
@@ -0,0 +1,11 @@
1
+ def serialize_result(obj: object) -> object:
2
+ """Convert Python/DB objects into JSON-serializable structures"""
3
+ if isinstance(obj, (str, int, float, bool, type(None))):
4
+ return obj
5
+ if isinstance(obj, (list, tuple)):
6
+ return [serialize_result(x) for x in obj]
7
+ if isinstance(obj, dict):
8
+ return {k: serialize_result(v) for k, v in obj.items()}
9
+ if hasattr(obj, "isoformat"):
10
+ return obj.isoformat()
11
+ return str(obj)
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.1
2
+ Name: ocientmcp
3
+ Author: Ocient Inc
4
+ Author-email: info@ocient.com
5
+ Home-page: https://www.ocient.com/
6
+ License: Apache License, Version 2.0
7
+ Description-Content-Type: text/markdown
8
+ Summary: Ocient MCP Server - Model Context Protocol integration for the OcientAIQ™ Unified Data Platform
9
+ Project-URL: documentation, https://docs.ocient.com/
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Topic :: Database
12
+ Classifier: Topic :: Database :: Front-Ends
13
+ Classifier: Topic :: Software Development
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10,<4
19
+ Requires-Dist: fastmcp>=3.4.0,<4
20
+ Requires-Dist: google-auth
21
+ Requires-Dist: httpx>=0.28.1
22
+ Requires-Dist: mcp>=1.29.0,<2
23
+ Requires-Dist: pydantic
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: requests
26
+ Requires-Dist: pyocient>=3.7.0
27
+ Version: 1.2.2
28
+
29
+ # OcientMCP
30
+
31
+ The OcientMCP server enables AI agents and tools such as Windsurf or Cascade to interact with the OcientAIQ™ Unified Data Platform using the Model Context Protocol (MCP). This server provides a standardized interface for querying, exploring, and analyzing data in the Ocient System.
32
+
@@ -0,0 +1,18 @@
1
+ ocientmcp/__init__.py,sha256=uc26kjoc4Ek4C1yYtdcxZ7Apv5aY4JtbcE965GxMyGI,869
2
+ ocientmcp/models.py,sha256=WJi0e832DDE3fM_K2NyRbEN8ZBnunxq_A-KiVKTS0KE,675
3
+ ocientmcp/pkg_version.py,sha256=oyfmQVJgxP0NR5vs9rKQyTTg8V_VjOBJ8hU4OYivRwA,118
4
+ ocientmcp/provider.py,sha256=-5mogZ_yhXPXtIqCMBG2EIpKotUHfm5fhQf6MQotM9Y,7033
5
+ ocientmcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ ocientmcp/server.py,sha256=v6BJL93xuwI1426hwWXkDRoHMB_M-wMuf6yfgFRk1BE,13956
7
+ ocientmcp/server_http.py,sha256=hF4luc6x8lI3z1xqPBd_OByBleypXopDhxucogmTYzQ,1444
8
+ ocientmcp/server_http_proxy.py,sha256=ZFgfKQunWpuiazwN8KI7dxRZd-2i6YBaaOQiqLwsXzc,2174
9
+ ocientmcp/server_stdio.py,sha256=Lxujb9-oP-Y4ODQUrhFfxxFZxsW0DBFapHq_ce2xGOQ,2868
10
+ ocientmcp/sso.py,sha256=r0jfheosQJYS1DwSZgbR1xhhJSl8TedDLl7DvxRPCLA,561
11
+ ocientmcp/token_cache.py,sha256=uLOcZbG7UHIs8YzlwZ6XNGRFWqvF5nEGCrGLkEW7aBw,3414
12
+ ocientmcp/tools.py,sha256=gE5yOyXCgNWC6YSt5y-6ZwJ_j2uW1Ga014a246i52yg,1872
13
+ ocientmcp/transports.py,sha256=sqKRhEMdvkziJitMdDe-2fH5cpdeShJ8J0lxPIzy9Vs,3908
14
+ ocientmcp/utils.py,sha256=8xSoRk9__Vc4tMgl_Gggaom-fusy7DnxndbvdYnDkdU,463
15
+ ocientmcp-1.2.2.dist-info/WHEEL,sha256=sobxWSyDDkdg_rinUth-jxhXHqoNqlmNMJY3aTZn2Us,91
16
+ ocientmcp-1.2.2.dist-info/METADATA,sha256=kyC0LWBE_oO1CTRA4nhkWFpaiX5l5fsr1j7fzYcfQDU,1306
17
+ ocientmcp-1.2.2.dist-info/entry_points.txt,sha256=WYtwwQeGYhGMF6_OjfmJ4GR-VXJL1_9239cqla3J4VA,157
18
+ ocientmcp-1.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bazel-wheelmaker 1.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ ocientmcp-http-proxy=ocientmcp.server_http_proxy:main
3
+ ocientmcp-http=ocientmcp.server_http:main
4
+ ocientmcp-stdio=ocientmcp.server_stdio:main