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 +25 -0
- ocientmcp/models.py +20 -0
- ocientmcp/pkg_version.py +4 -0
- ocientmcp/provider.py +198 -0
- ocientmcp/py.typed +0 -0
- ocientmcp/server.py +344 -0
- ocientmcp/server_http.py +55 -0
- ocientmcp/server_http_proxy.py +70 -0
- ocientmcp/server_stdio.py +90 -0
- ocientmcp/sso.py +16 -0
- ocientmcp/token_cache.py +106 -0
- ocientmcp/tools.py +59 -0
- ocientmcp/transports.py +127 -0
- ocientmcp/utils.py +11 -0
- ocientmcp-1.2.2.dist-info/METADATA +32 -0
- ocientmcp-1.2.2.dist-info/RECORD +18 -0
- ocientmcp-1.2.2.dist-info/WHEEL +4 -0
- ocientmcp-1.2.2.dist-info/entry_points.txt +4 -0
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"""
|
ocientmcp/pkg_version.py
ADDED
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()
|
ocientmcp/server_http.py
ADDED
|
@@ -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
|
ocientmcp/token_cache.py
ADDED
|
@@ -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
|
ocientmcp/transports.py
ADDED
|
@@ -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,,
|