gnosys-strata 1.1.4__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.
- gnosys_strata-1.1.4.dist-info/METADATA +140 -0
- gnosys_strata-1.1.4.dist-info/RECORD +28 -0
- gnosys_strata-1.1.4.dist-info/WHEEL +4 -0
- gnosys_strata-1.1.4.dist-info/entry_points.txt +2 -0
- strata/__init__.py +6 -0
- strata/__main__.py +6 -0
- strata/cli.py +364 -0
- strata/config.py +310 -0
- strata/logging_config.py +109 -0
- strata/main.py +6 -0
- strata/mcp_client_manager.py +282 -0
- strata/mcp_proxy/__init__.py +7 -0
- strata/mcp_proxy/auth_provider.py +200 -0
- strata/mcp_proxy/client.py +162 -0
- strata/mcp_proxy/transport/__init__.py +7 -0
- strata/mcp_proxy/transport/base.py +104 -0
- strata/mcp_proxy/transport/http.py +80 -0
- strata/mcp_proxy/transport/stdio.py +69 -0
- strata/server.py +216 -0
- strata/tools.py +714 -0
- strata/treeshell_functions.py +397 -0
- strata/utils/__init__.py +0 -0
- strata/utils/bm25_search.py +181 -0
- strata/utils/catalog.py +82 -0
- strata/utils/dict_utils.py +29 -0
- strata/utils/field_search.py +233 -0
- strata/utils/shared_search.py +202 -0
- strata/utils/tool_integration.py +269 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Field-based weighted search engine
|
|
3
|
+
|
|
4
|
+
A simpler alternative to BM25 that focuses on field-level matching
|
|
5
|
+
with explicit weights and avoids document length bias.
|
|
6
|
+
|
|
7
|
+
## Algorithm Design
|
|
8
|
+
|
|
9
|
+
This search engine implements a three-layer scoring system to prevent score explosion
|
|
10
|
+
and ensure relevant results:
|
|
11
|
+
|
|
12
|
+
### Layer 1: Match Quality Scoring
|
|
13
|
+
For each token-field match, we assign a base score based on match quality:
|
|
14
|
+
- Exact match: weight × 3.0 (e.g., field value "projects" == query "projects")
|
|
15
|
+
- Word boundary match: weight × 2.0 (e.g., "list projects" matches query "projects" as complete word)
|
|
16
|
+
- Partial match: weight × 1.0 (e.g., "project_list" contains query "project")
|
|
17
|
+
|
|
18
|
+
### Layer 2: Intra-field Token Decay (Harmonic Series)
|
|
19
|
+
When multiple query tokens match within the same field, we apply diminishing returns:
|
|
20
|
+
- 1st token: 100% of score
|
|
21
|
+
- 2nd token: 50% of score (1/2)
|
|
22
|
+
- 3rd token: 33% of score (1/3)
|
|
23
|
+
- 4th token: 25% of score (1/4)
|
|
24
|
+
|
|
25
|
+
This prevents long descriptions from accumulating excessive scores by matching many tokens.
|
|
26
|
+
|
|
27
|
+
### Layer 3: Per-field Logarithmic Dampening
|
|
28
|
+
After calculating field scores, we apply logarithmic dampening based on field type:
|
|
29
|
+
- Description fields (description, param_desc): log(1 + score) × 5 (stronger dampening)
|
|
30
|
+
- Identifier fields (service, operation, tag, path, etc.): log(1 + score) × 10 (lighter dampening)
|
|
31
|
+
|
|
32
|
+
This prevents any single field from dominating the final score, especially verbose fields.
|
|
33
|
+
|
|
34
|
+
### Final Score Calculation
|
|
35
|
+
- Sum all dampened field scores
|
|
36
|
+
- Add diversity bonus: sqrt(matched_field_types) × 3
|
|
37
|
+
(rewards matching across multiple field types)
|
|
38
|
+
|
|
39
|
+
## Problem Scenarios This Solves
|
|
40
|
+
|
|
41
|
+
### Scenario 1: Keyword Repetition
|
|
42
|
+
Query: "projects"
|
|
43
|
+
Without dampening:
|
|
44
|
+
- Endpoint A: service="projects"(90) + tag="projects"(90) + path="/projects"(60) +
|
|
45
|
+
description="manage projects"(20) = 260 points
|
|
46
|
+
- Endpoint B: service="users"(0) + operation="get_user_projects"(60) = 60 points
|
|
47
|
+
|
|
48
|
+
With our algorithm:
|
|
49
|
+
- Endpoint A: log(91)×10 + log(91)×10 + log(61)×10 + log(21)×5 = 45.5+45.5+40.2+15.2 = 146.4
|
|
50
|
+
- Endpoint B: log(61)×10 = 40.2
|
|
51
|
+
|
|
52
|
+
Still favors A but with reasonable margin, not 4x difference.
|
|
53
|
+
|
|
54
|
+
### Scenario 2: Long Description Domination
|
|
55
|
+
Query: "create user project pipeline"
|
|
56
|
+
Without dampening:
|
|
57
|
+
- Endpoint A: description contains all 4 words = 20×4 = 80 points
|
|
58
|
+
- Endpoint B: operation="create_pipeline" = 30×2 = 60 points
|
|
59
|
+
|
|
60
|
+
With our algorithm:
|
|
61
|
+
- Endpoint A: (20 + 20/2 + 20/3 + 20/4) = 41.7 → log(42.7)×5 = 18.6 points
|
|
62
|
+
- Endpoint B: 30×2 = 60 → log(61)×10 = 40.2 points
|
|
63
|
+
|
|
64
|
+
Now B correctly ranks higher as it's more specific.
|
|
65
|
+
|
|
66
|
+
### Scenario 3: Exact Service Name Match
|
|
67
|
+
Query: "projects"
|
|
68
|
+
- Service name exactly "projects": 30×3=90 → log(91)×10 = 45.5 points
|
|
69
|
+
This ensures exact matches still get high scores despite dampening.
|
|
70
|
+
|
|
71
|
+
## Weight Configuration
|
|
72
|
+
|
|
73
|
+
Weights should be configured based on field importance:
|
|
74
|
+
- High (30): service, operation, tag, path - core identifiers
|
|
75
|
+
- Medium (20): summary, description - contextual information
|
|
76
|
+
- Low (5): method, param - auxiliary information
|
|
77
|
+
- Minimal (1-2): param_desc, body_field - verbose/detailed fields
|
|
78
|
+
|
|
79
|
+
The weights are passed during document indexing, allowing different OpenAPI
|
|
80
|
+
implementations to customize based on their documentation structure.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
import math
|
|
84
|
+
import re
|
|
85
|
+
from typing import List, Tuple
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FieldSearchEngine:
|
|
89
|
+
"""
|
|
90
|
+
Simple field-based search engine with weighted scoring
|
|
91
|
+
Compatible with BM25SearchEngine interface
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, **kwargs):
|
|
95
|
+
"""Initialize the search engine (kwargs for compatibility with BM25SearchEngine)"""
|
|
96
|
+
self.documents = []
|
|
97
|
+
self.corpus_metadata = None
|
|
98
|
+
|
|
99
|
+
def build_index(self, documents: List[Tuple[List[Tuple[str, str, int]], str]]):
|
|
100
|
+
"""
|
|
101
|
+
Build index from documents
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
documents: List of (fields, doc_id) tuples
|
|
105
|
+
fields: List of (field_key, field_value, weight) tuples
|
|
106
|
+
weight is used as field priority
|
|
107
|
+
doc_id: Document identifier string
|
|
108
|
+
"""
|
|
109
|
+
self.documents = []
|
|
110
|
+
self.corpus_metadata = []
|
|
111
|
+
|
|
112
|
+
for fields, doc_id in documents:
|
|
113
|
+
# Store document with structured fields and their weights
|
|
114
|
+
doc_fields = {}
|
|
115
|
+
field_weights = {}
|
|
116
|
+
|
|
117
|
+
for field_key, field_value, weight in fields:
|
|
118
|
+
if field_value:
|
|
119
|
+
# Group values by field type
|
|
120
|
+
if field_key not in doc_fields:
|
|
121
|
+
doc_fields[field_key] = []
|
|
122
|
+
field_weights[field_key] = weight
|
|
123
|
+
doc_fields[field_key].append(field_value.lower())
|
|
124
|
+
|
|
125
|
+
# Use the highest weight if multiple values for same field
|
|
126
|
+
if weight > field_weights.get(field_key, 0):
|
|
127
|
+
field_weights[field_key] = weight
|
|
128
|
+
|
|
129
|
+
self.documents.append(
|
|
130
|
+
{"id": doc_id, "fields": doc_fields, "weights": field_weights}
|
|
131
|
+
)
|
|
132
|
+
self.corpus_metadata.append(doc_id)
|
|
133
|
+
|
|
134
|
+
def search(self, query: str, top_k: int = 10) -> List[Tuple[float, str]]:
|
|
135
|
+
"""
|
|
136
|
+
Search documents with field-weighted scoring and logarithmic dampening
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
query: Search query string
|
|
140
|
+
top_k: Number of top results to return
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
List of (score, doc_id) tuples sorted by score descending
|
|
144
|
+
"""
|
|
145
|
+
if not self.documents:
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
# Tokenize query into words
|
|
149
|
+
query_tokens = query.lower().split()
|
|
150
|
+
|
|
151
|
+
results = []
|
|
152
|
+
|
|
153
|
+
for doc in self.documents:
|
|
154
|
+
# Track scores by field type to apply per-field dampening
|
|
155
|
+
field_scores = {}
|
|
156
|
+
matched_field_types = set()
|
|
157
|
+
|
|
158
|
+
# Check each field type
|
|
159
|
+
for field_type, field_values in doc["fields"].items():
|
|
160
|
+
# Get weight from document's field weights
|
|
161
|
+
field_weight = doc["weights"].get(field_type, 1.0)
|
|
162
|
+
|
|
163
|
+
# Track tokens matched in this field
|
|
164
|
+
field_token_scores = []
|
|
165
|
+
matched_tokens = set()
|
|
166
|
+
|
|
167
|
+
# For each query token
|
|
168
|
+
for token in query_tokens:
|
|
169
|
+
# Check if token appears in any value of this field
|
|
170
|
+
best_match_score = 0
|
|
171
|
+
|
|
172
|
+
for value in field_values:
|
|
173
|
+
if (
|
|
174
|
+
self._match_token(token, value)
|
|
175
|
+
and token not in matched_tokens
|
|
176
|
+
):
|
|
177
|
+
# Calculate match quality
|
|
178
|
+
match_score = 0
|
|
179
|
+
|
|
180
|
+
# Exact match gets highest score
|
|
181
|
+
if value == token:
|
|
182
|
+
match_score = 3.0
|
|
183
|
+
# Word boundary match (complete word)
|
|
184
|
+
elif re.search(r"\b" + re.escape(token) + r"\b", value):
|
|
185
|
+
match_score = 2.0
|
|
186
|
+
# Partial match gets base score
|
|
187
|
+
else:
|
|
188
|
+
match_score = 1.0
|
|
189
|
+
|
|
190
|
+
best_match_score = max(best_match_score, match_score)
|
|
191
|
+
|
|
192
|
+
if best_match_score > 0:
|
|
193
|
+
matched_tokens.add(token)
|
|
194
|
+
field_token_scores.append(field_weight * best_match_score)
|
|
195
|
+
matched_field_types.add(field_type)
|
|
196
|
+
|
|
197
|
+
# Apply diminishing returns for multiple tokens in same field
|
|
198
|
+
if field_token_scores:
|
|
199
|
+
# Sort scores in descending order
|
|
200
|
+
field_token_scores.sort(reverse=True)
|
|
201
|
+
|
|
202
|
+
# Apply decay: 1st token 100%, 2nd 50%, 3rd 33%, etc.
|
|
203
|
+
field_total = 0
|
|
204
|
+
for i, token_score in enumerate(field_token_scores):
|
|
205
|
+
field_total += token_score / (i + 1)
|
|
206
|
+
|
|
207
|
+
# Apply logarithmic dampening per field to prevent single field domination
|
|
208
|
+
# This prevents description or other verbose fields from dominating
|
|
209
|
+
if field_type in ["description", "param_desc"]:
|
|
210
|
+
# Stronger dampening for description fields
|
|
211
|
+
field_scores[field_type] = math.log(1 + field_total) * 5
|
|
212
|
+
else:
|
|
213
|
+
# Lighter dampening for identifier fields
|
|
214
|
+
field_scores[field_type] = math.log(1 + field_total) * 10
|
|
215
|
+
|
|
216
|
+
# Calculate final score
|
|
217
|
+
if field_scores:
|
|
218
|
+
# Sum all field scores (already dampened per field)
|
|
219
|
+
total_score = sum(field_scores.values())
|
|
220
|
+
|
|
221
|
+
# Add diversity bonus for matching multiple field types
|
|
222
|
+
diversity_bonus = math.sqrt(len(matched_field_types)) * 3
|
|
223
|
+
|
|
224
|
+
final_score = total_score + diversity_bonus
|
|
225
|
+
results.append((final_score, doc["id"]))
|
|
226
|
+
|
|
227
|
+
# Sort by score descending and return top k
|
|
228
|
+
results.sort(key=lambda x: x[0], reverse=True)
|
|
229
|
+
return results[:top_k]
|
|
230
|
+
|
|
231
|
+
def _match_token(self, token: str, text: str) -> bool:
|
|
232
|
+
"""Check if token matches in text"""
|
|
233
|
+
return token in text
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared search utility for both single_server and strata_server.
|
|
3
|
+
Provides type-safe interfaces for searching through MCP tools
|
|
4
|
+
Uses a unified generic approach to reduce code duplication.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from mcp import types
|
|
10
|
+
|
|
11
|
+
from strata.utils.bm25_search import BM25SearchEngine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UniversalToolSearcher:
|
|
15
|
+
"""
|
|
16
|
+
Universal searcher that handles all tool types
|
|
17
|
+
using a single unified approach based on function names.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, mixed_tools_map: Dict[str, List[Any]]):
|
|
21
|
+
"""
|
|
22
|
+
Initialize universal searcher with mixed tool types.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
mixed_tools_map: Dictionary mapping categories to tools.
|
|
26
|
+
Tools can be either types.Tool objects or dict objects.
|
|
27
|
+
"""
|
|
28
|
+
self.tools_map = mixed_tools_map
|
|
29
|
+
self.search_engine = self._build_index()
|
|
30
|
+
|
|
31
|
+
def _get_tool_name(self, tool: Any) -> Optional[str]:
|
|
32
|
+
"""Extract name from any tool type."""
|
|
33
|
+
if isinstance(tool, types.Tool):
|
|
34
|
+
return tool.name if tool.name else None
|
|
35
|
+
elif isinstance(tool, dict):
|
|
36
|
+
return tool.get("name")
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def _get_tool_field(self, tool: Any, field_name: str, default: Any = None) -> Any:
|
|
40
|
+
"""Extract field value from any tool type."""
|
|
41
|
+
if isinstance(tool, types.Tool):
|
|
42
|
+
return getattr(tool, field_name, default)
|
|
43
|
+
elif isinstance(tool, dict):
|
|
44
|
+
return tool.get(field_name, default)
|
|
45
|
+
return default
|
|
46
|
+
|
|
47
|
+
def _build_index(self) -> BM25SearchEngine:
|
|
48
|
+
"""Build unified search index from all tools."""
|
|
49
|
+
documents = []
|
|
50
|
+
|
|
51
|
+
for category_name, tools in self.tools_map.items():
|
|
52
|
+
for tool in tools:
|
|
53
|
+
# Get tool name (function name)
|
|
54
|
+
tool_name = self._get_tool_name(tool)
|
|
55
|
+
if not tool_name:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
# Build weighted fields
|
|
59
|
+
fields = []
|
|
60
|
+
|
|
61
|
+
# Core identifiers - highest weight
|
|
62
|
+
fields.append(("category", category_name.lower(), 30))
|
|
63
|
+
fields.append(("operation", tool_name.lower(), 30))
|
|
64
|
+
|
|
65
|
+
# Title if available
|
|
66
|
+
title = self._get_tool_field(tool, "title", "")
|
|
67
|
+
if title:
|
|
68
|
+
fields.append(("title", str(title).lower(), 30))
|
|
69
|
+
|
|
70
|
+
# Description/Summary - highest weight
|
|
71
|
+
description = self._get_tool_field(tool, "description", "")
|
|
72
|
+
if description:
|
|
73
|
+
fields.append(("description", str(description).lower(), 30))
|
|
74
|
+
|
|
75
|
+
summary = self._get_tool_field(tool, "summary", "")
|
|
76
|
+
if summary:
|
|
77
|
+
fields.append(("summary", str(summary).lower(), 30))
|
|
78
|
+
|
|
79
|
+
tags = self._get_tool_field(tool, "tags", [])
|
|
80
|
+
if isinstance(tags, list):
|
|
81
|
+
for tag in tags:
|
|
82
|
+
if tag:
|
|
83
|
+
fields.append(("tag", str(tag).lower(), 30))
|
|
84
|
+
|
|
85
|
+
path = self._get_tool_field(tool, "path", "")
|
|
86
|
+
if path:
|
|
87
|
+
fields.append(("path", str(path).lower(), 30))
|
|
88
|
+
|
|
89
|
+
method = self._get_tool_field(tool, "method", "")
|
|
90
|
+
if method:
|
|
91
|
+
fields.append(("method", str(method).lower(), 15))
|
|
92
|
+
|
|
93
|
+
for param_type in ["path_params", "query_params"]:
|
|
94
|
+
params = self._get_tool_field(tool, param_type, {})
|
|
95
|
+
for param_name, param_info in params.items():
|
|
96
|
+
fields.append(
|
|
97
|
+
(f"{param_type}/{param_name}", param_name.lower(), 15)
|
|
98
|
+
)
|
|
99
|
+
if isinstance(param_info, dict):
|
|
100
|
+
param_desc = param_info.get("description", "")
|
|
101
|
+
if param_desc:
|
|
102
|
+
fields.append(
|
|
103
|
+
(
|
|
104
|
+
f"{param_type}/{param_name}_desc",
|
|
105
|
+
param_desc.lower(),
|
|
106
|
+
15,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Body schema fields
|
|
111
|
+
body_schema = self._get_tool_field(tool, "body_schema", {})
|
|
112
|
+
for param_name, param_info in body_schema.get("properties", {}).items():
|
|
113
|
+
fields.append((f"body_schema/{param_name}", param_name.lower(), 15))
|
|
114
|
+
if isinstance(param_info, dict):
|
|
115
|
+
param_desc = param_info.get("description", "")
|
|
116
|
+
if param_desc:
|
|
117
|
+
fields.append(
|
|
118
|
+
(
|
|
119
|
+
f"body_schema/{param_name}_desc",
|
|
120
|
+
param_desc.lower(),
|
|
121
|
+
15,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
response_schema = self._get_tool_field(tool, "response_schema", {})
|
|
126
|
+
for param_name, param_info in response_schema.get(
|
|
127
|
+
"properties", {}
|
|
128
|
+
).items():
|
|
129
|
+
fields.append(
|
|
130
|
+
(f"response_schema/{param_name}", param_name.lower(), 5)
|
|
131
|
+
)
|
|
132
|
+
if isinstance(param_info, dict):
|
|
133
|
+
param_desc = param_info.get("description", "")
|
|
134
|
+
if param_desc:
|
|
135
|
+
fields.append(
|
|
136
|
+
(
|
|
137
|
+
f"response_schema/{param_name}_desc",
|
|
138
|
+
param_desc.lower(),
|
|
139
|
+
5,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Create document ID
|
|
144
|
+
doc_id = f"{category_name}::{tool_name}"
|
|
145
|
+
if fields:
|
|
146
|
+
documents.append((fields, doc_id))
|
|
147
|
+
|
|
148
|
+
# Build search index
|
|
149
|
+
search_engine = BM25SearchEngine()
|
|
150
|
+
search_engine.build_index(documents)
|
|
151
|
+
return search_engine
|
|
152
|
+
|
|
153
|
+
def search(self, query: str, max_results: int = 10) -> List[Dict[str, Any]]:
|
|
154
|
+
"""
|
|
155
|
+
Search through all tools.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
query: Search query string
|
|
159
|
+
max_results: Maximum number of results to return
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
List of search results with tool information
|
|
163
|
+
"""
|
|
164
|
+
if self.search_engine is None:
|
|
165
|
+
return []
|
|
166
|
+
|
|
167
|
+
# Perform search
|
|
168
|
+
search_results = self.search_engine.search(query.lower(), top_k=max_results)
|
|
169
|
+
|
|
170
|
+
# Build results
|
|
171
|
+
results = []
|
|
172
|
+
|
|
173
|
+
for score, doc_id in search_results:
|
|
174
|
+
# Parse doc_id
|
|
175
|
+
if "::" not in doc_id:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
category_name, tool_name = doc_id.split("::", 1)
|
|
179
|
+
|
|
180
|
+
# Find the tool
|
|
181
|
+
if category_name in self.tools_map:
|
|
182
|
+
for tool in self.tools_map[category_name]:
|
|
183
|
+
if self._get_tool_name(tool) == tool_name:
|
|
184
|
+
result = {
|
|
185
|
+
"name": tool_name,
|
|
186
|
+
"description": self._get_tool_field(
|
|
187
|
+
tool, "description", ""
|
|
188
|
+
),
|
|
189
|
+
"category_name": category_name,
|
|
190
|
+
# "relevance_score": score,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Add optional fields if they exist
|
|
194
|
+
for field in ["title", "summary"]:
|
|
195
|
+
value = self._get_tool_field(tool, field)
|
|
196
|
+
if value:
|
|
197
|
+
result[field] = value
|
|
198
|
+
|
|
199
|
+
results.append(result)
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
return results
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Tool integration utilities for adding Strata to various IDEs and editors."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def update_json_recursively(data: dict, keys: list, value) -> dict:
|
|
10
|
+
"""Recursively update a nested dictionary, creating keys as needed.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
data: The dictionary to update
|
|
14
|
+
keys: List of keys representing the path to update
|
|
15
|
+
value: The value to set at the path
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The updated dictionary
|
|
19
|
+
"""
|
|
20
|
+
if not keys:
|
|
21
|
+
return data
|
|
22
|
+
|
|
23
|
+
if len(keys) == 1:
|
|
24
|
+
# Base case: set the value
|
|
25
|
+
key = keys[0]
|
|
26
|
+
if isinstance(data.get(key), dict) and isinstance(value, dict):
|
|
27
|
+
# Merge dictionaries if both are dict type
|
|
28
|
+
data[key] = {**data.get(key, {}), **value}
|
|
29
|
+
else:
|
|
30
|
+
data[key] = value
|
|
31
|
+
return data
|
|
32
|
+
|
|
33
|
+
# Recursive case: ensure intermediate keys exist
|
|
34
|
+
key = keys[0]
|
|
35
|
+
if key not in data:
|
|
36
|
+
data[key] = {}
|
|
37
|
+
elif not isinstance(data[key], dict):
|
|
38
|
+
# If the existing value is not a dict, replace it with dict
|
|
39
|
+
data[key] = {}
|
|
40
|
+
|
|
41
|
+
data[key] = update_json_recursively(data[key], keys[1:], value)
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def ensure_json_config(config_path: Path) -> dict:
|
|
46
|
+
"""Ensure JSON configuration file exists and return its content.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
config_path: Path to the configuration file
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The configuration dictionary
|
|
53
|
+
"""
|
|
54
|
+
# Create directory if it doesn't exist
|
|
55
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
|
|
57
|
+
# Load existing config or create empty one
|
|
58
|
+
if config_path.exists():
|
|
59
|
+
try:
|
|
60
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
61
|
+
return json.load(f)
|
|
62
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
63
|
+
print(
|
|
64
|
+
f"Warning: Could not read existing config {config_path}: {e}",
|
|
65
|
+
file=sys.stderr,
|
|
66
|
+
)
|
|
67
|
+
print("Creating new configuration file", file=sys.stderr)
|
|
68
|
+
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_json_config(config_path: Path, config: dict) -> None:
|
|
73
|
+
"""Save JSON configuration to file.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
config_path: Path to save the configuration
|
|
77
|
+
config: Configuration dictionary to save
|
|
78
|
+
"""
|
|
79
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
81
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def check_cli_available(target: str) -> bool:
|
|
85
|
+
"""Check if a CLI tool is available.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
target: Name of the CLI tool to check
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
True if the CLI tool is available, False otherwise
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
if target == "vscode":
|
|
95
|
+
# Check for VSCode CLI (code command)
|
|
96
|
+
result = subprocess.run(
|
|
97
|
+
["code", "--version"], capture_output=True, text=True, timeout=5
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
[target, "--version"], capture_output=True, text=True, timeout=5
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return result.returncode == 0
|
|
105
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def add_strata_to_cursor(scope: str = "user") -> int:
|
|
110
|
+
"""Add Strata to Cursor MCP configuration.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
scope: Configuration scope (user, project, or local)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
0 on success, 1 on error
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
# Determine config path based on scope
|
|
120
|
+
if scope == "user":
|
|
121
|
+
# User scope: ~/.cursor/mcp.json
|
|
122
|
+
cursor_config_path = Path.home() / ".cursor" / "mcp.json"
|
|
123
|
+
elif scope in ["project", "local"]:
|
|
124
|
+
# Project scope: .cursor/mcp.json in current directory
|
|
125
|
+
cursor_config_path = Path.cwd() / ".cursor" / "mcp.json"
|
|
126
|
+
else:
|
|
127
|
+
print(
|
|
128
|
+
f"Error: Unsupported scope '{scope}' for Cursor. Supported: user, project, local",
|
|
129
|
+
file=sys.stderr,
|
|
130
|
+
)
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
print(
|
|
134
|
+
f"Adding Strata to Cursor with scope '{scope}' at {cursor_config_path}..."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Load or create cursor configuration
|
|
138
|
+
cursor_config = ensure_json_config(cursor_config_path)
|
|
139
|
+
|
|
140
|
+
# Create Strata server configuration for Cursor
|
|
141
|
+
strata_server_config = {"command": "strata", "args": []}
|
|
142
|
+
|
|
143
|
+
# Update configuration using recursive update
|
|
144
|
+
cursor_config = update_json_recursively(
|
|
145
|
+
cursor_config, ["mcpServers", "strata"], strata_server_config
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Save updated configuration
|
|
149
|
+
save_json_config(cursor_config_path, cursor_config)
|
|
150
|
+
print("✓ Successfully added Strata to Cursor MCP configuration")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
except (IOError, OSError) as e:
|
|
154
|
+
print(f"Error handling Cursor configuration: {e}", file=sys.stderr)
|
|
155
|
+
return 1
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def add_strata_to_vscode() -> int:
|
|
159
|
+
"""Add Strata to VSCode MCP configuration.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
0 on success, 1 on error
|
|
163
|
+
"""
|
|
164
|
+
try:
|
|
165
|
+
# VSCode uses JSON format: code --add-mcp '{"name":"strata","command":"strata"}'
|
|
166
|
+
mcp_config = {"name": "strata", "command": "strata"}
|
|
167
|
+
mcp_json = json.dumps(mcp_config)
|
|
168
|
+
|
|
169
|
+
print("Adding Strata to VSCode...")
|
|
170
|
+
cmd = ["code", "--add-mcp", mcp_json]
|
|
171
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
172
|
+
|
|
173
|
+
if result.returncode == 0:
|
|
174
|
+
print("✓ Successfully added Strata to VSCode MCP configuration")
|
|
175
|
+
if result.stdout.strip():
|
|
176
|
+
print(result.stdout)
|
|
177
|
+
return 0
|
|
178
|
+
else:
|
|
179
|
+
print(
|
|
180
|
+
f"Error adding Strata to VSCode: {result.stderr.strip()}",
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
return result.returncode
|
|
184
|
+
|
|
185
|
+
except subprocess.SubprocessError as e:
|
|
186
|
+
print(f"Error running VSCode command: {e}", file=sys.stderr)
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def add_strata_to_claude_or_gemini(target: str, scope: str = "user") -> int:
|
|
191
|
+
"""Add Strata to Claude or Gemini MCP configuration.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
target: Target CLI tool (claude or gemini)
|
|
195
|
+
scope: Configuration scope
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
0 on success, 1 on error
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
# Claude and Gemini use the original format
|
|
202
|
+
cmd = [target, "mcp", "add"]
|
|
203
|
+
cmd.extend(["--scope", scope])
|
|
204
|
+
cmd.extend(["strata", "strata"])
|
|
205
|
+
|
|
206
|
+
print(f"Adding Strata to {target} with scope '{scope}'...")
|
|
207
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
208
|
+
|
|
209
|
+
if result.returncode == 0:
|
|
210
|
+
print(f"✓ Successfully added Strata to {target} MCP configuration")
|
|
211
|
+
if result.stdout.strip():
|
|
212
|
+
print(result.stdout)
|
|
213
|
+
return 0
|
|
214
|
+
else:
|
|
215
|
+
print(
|
|
216
|
+
f"Error adding Strata to {target}: {result.stderr.strip()}",
|
|
217
|
+
file=sys.stderr,
|
|
218
|
+
)
|
|
219
|
+
return result.returncode
|
|
220
|
+
|
|
221
|
+
except subprocess.SubprocessError as e:
|
|
222
|
+
print(f"Error running {target} command: {e}", file=sys.stderr)
|
|
223
|
+
return 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def add_strata_to_tool(target: str, scope: str = "user") -> int:
|
|
227
|
+
"""Add Strata MCP server to specified tool configuration.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
target: Target tool (claude, gemini, vscode, cursor)
|
|
231
|
+
scope: Configuration scope
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
0 on success, 1 on error
|
|
235
|
+
"""
|
|
236
|
+
target = target.lower()
|
|
237
|
+
|
|
238
|
+
# Validate target
|
|
239
|
+
if target not in ["claude", "gemini", "vscode", "cursor"]:
|
|
240
|
+
print(
|
|
241
|
+
f"Error: Unsupported target '{target}'. Supported targets: claude, gemini, vscode, cursor",
|
|
242
|
+
file=sys.stderr,
|
|
243
|
+
)
|
|
244
|
+
return 1
|
|
245
|
+
|
|
246
|
+
# VSCode doesn't support scope parameter
|
|
247
|
+
if target == "vscode" and scope != "user":
|
|
248
|
+
print(
|
|
249
|
+
"Warning: VSCode doesn't support scope parameter, using default behavior",
|
|
250
|
+
file=sys.stderr,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Check if the target CLI is available (skip for cursor as we handle files directly)
|
|
254
|
+
if target != "cursor":
|
|
255
|
+
if not check_cli_available(target):
|
|
256
|
+
cli_name = "code" if target == "vscode" else target
|
|
257
|
+
print(
|
|
258
|
+
f"Error: {cli_name} CLI not found. Please install {cli_name} CLI first.",
|
|
259
|
+
file=sys.stderr,
|
|
260
|
+
)
|
|
261
|
+
return 1
|
|
262
|
+
|
|
263
|
+
# Handle each target
|
|
264
|
+
if target == "cursor":
|
|
265
|
+
return add_strata_to_cursor(scope)
|
|
266
|
+
elif target == "vscode":
|
|
267
|
+
return add_strata_to_vscode()
|
|
268
|
+
else: # claude or gemini
|
|
269
|
+
return add_strata_to_claude_or_gemini(target, scope)
|