simlir-mcp 0.1.0__tar.gz
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.
- simlir_mcp-0.1.0/LICENSE +1 -0
- simlir_mcp-0.1.0/PKG-INFO +86 -0
- simlir_mcp-0.1.0/README.md +70 -0
- simlir_mcp-0.1.0/pyproject.toml +27 -0
- simlir_mcp-0.1.0/setup.cfg +4 -0
- simlir_mcp-0.1.0/src/simlir_mcp/__init__.py +5 -0
- simlir_mcp-0.1.0/src/simlir_mcp/__main__.py +5 -0
- simlir_mcp-0.1.0/src/simlir_mcp/protocol.py +195 -0
- simlir_mcp-0.1.0/src/simlir_mcp/server.py +66 -0
- simlir_mcp-0.1.0/src/simlir_mcp.egg-info/PKG-INFO +86 -0
- simlir_mcp-0.1.0/src/simlir_mcp.egg-info/SOURCES.txt +15 -0
- simlir_mcp-0.1.0/src/simlir_mcp.egg-info/dependency_links.txt +1 -0
- simlir_mcp-0.1.0/src/simlir_mcp.egg-info/entry_points.txt +2 -0
- simlir_mcp-0.1.0/src/simlir_mcp.egg-info/requires.txt +1 -0
- simlir_mcp-0.1.0/src/simlir_mcp.egg-info/top_level.txt +1 -0
- simlir_mcp-0.1.0/tests/test_pypi_metadata.spec.py +25 -0
- simlir_mcp-0.1.0/tests/test_server.py +87 -0
simlir_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
UNLICENSED — publication and legal licensing gate pending.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: simlir-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A thin stdio MCP bridge for the simlir public product API
|
|
5
|
+
Author: simlir engineering
|
|
6
|
+
License: UNLICENSED — publication and legal licensing gate pending.
|
|
7
|
+
|
|
8
|
+
Keywords: mcp,model context protocol,product data,product search,ai
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: simlir<0.2.0,>=0.1.0
|
|
16
|
+
|
|
17
|
+
# simlir-mcp
|
|
18
|
+
|
|
19
|
+
Release candidate for a thin Python stdio adapter around the simlir public
|
|
20
|
+
product API. It is intentionally separate from Simlir’s internal
|
|
21
|
+
repository-intelligence MCP server and contains no ingestion code, customer
|
|
22
|
+
data, database credentials, or internal repository tooling.
|
|
23
|
+
|
|
24
|
+
The PyPI publication workflow is configured, but the package remains
|
|
25
|
+
unpublished pending legal licensing, public repository permissions, an assigned
|
|
26
|
+
engineering-maintainer role, PyPI name/policy checks, stable API deployment,
|
|
27
|
+
and release rollback evidence. Do not use the package name from a public
|
|
28
|
+
registry until those gates are recorded.
|
|
29
|
+
|
|
30
|
+
## Local use
|
|
31
|
+
|
|
32
|
+
Install the SDK and this adapter from the local release candidates:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pip install --no-deps ../python-sdk
|
|
36
|
+
python -m pip install --no-deps .
|
|
37
|
+
export SIMLIR_API_KEY=sk_your_key_here
|
|
38
|
+
python -m simlir_mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The adapter keeps JSON-RPC responses on stdout and writes only structured,
|
|
42
|
+
metadata-only diagnostics to stderr. Never put an API key in a checked-in
|
|
43
|
+
configuration file.
|
|
44
|
+
|
|
45
|
+
## Claude Desktop, Cursor, or VS Code
|
|
46
|
+
|
|
47
|
+
After the external publication and stable-URL gates pass, the intended local
|
|
48
|
+
configuration is:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"simlir": {
|
|
54
|
+
"command": "simlir-mcp",
|
|
55
|
+
"env": {
|
|
56
|
+
"SIMLIR_API_KEY": "${SIMLIR_API_KEY}"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Supported tools
|
|
64
|
+
|
|
65
|
+
| Tool | API operation |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `simlir_search_products` | Semantic product search |
|
|
68
|
+
| `simlir_search_products_by_image` | Visual product search with optional natural-language context |
|
|
69
|
+
| `simlir_lookup_products_by_identifier` | Exact GTIN/MPN/SKU lookup |
|
|
70
|
+
|
|
71
|
+
Tool names, required arguments, markets, and response shapes are pinned to the
|
|
72
|
+
checked-in public contract. Update the contract first, then regenerate or
|
|
73
|
+
update both MCP adapters and their tests when the public API changes.
|
|
74
|
+
|
|
75
|
+
The image tool requires `image_url` and `market`. Its optional `query` field
|
|
76
|
+
adds shopper context such as a size, product role, compatibility requirement,
|
|
77
|
+
or use case; Simlir combines that context with the visual signal. Image-only
|
|
78
|
+
calls remain valid.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
PYTHONPATH=../python-sdk/src:src python -m unittest discover -s tests -p 'test_*.py'
|
|
84
|
+
python -m compileall -q src
|
|
85
|
+
python -m pip wheel --no-deps --no-build-isolation --wheel-dir /tmp/simlir-python-mcp-wheel .
|
|
86
|
+
```
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# simlir-mcp
|
|
2
|
+
|
|
3
|
+
Release candidate for a thin Python stdio adapter around the simlir public
|
|
4
|
+
product API. It is intentionally separate from Simlir’s internal
|
|
5
|
+
repository-intelligence MCP server and contains no ingestion code, customer
|
|
6
|
+
data, database credentials, or internal repository tooling.
|
|
7
|
+
|
|
8
|
+
The PyPI publication workflow is configured, but the package remains
|
|
9
|
+
unpublished pending legal licensing, public repository permissions, an assigned
|
|
10
|
+
engineering-maintainer role, PyPI name/policy checks, stable API deployment,
|
|
11
|
+
and release rollback evidence. Do not use the package name from a public
|
|
12
|
+
registry until those gates are recorded.
|
|
13
|
+
|
|
14
|
+
## Local use
|
|
15
|
+
|
|
16
|
+
Install the SDK and this adapter from the local release candidates:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m pip install --no-deps ../python-sdk
|
|
20
|
+
python -m pip install --no-deps .
|
|
21
|
+
export SIMLIR_API_KEY=sk_your_key_here
|
|
22
|
+
python -m simlir_mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The adapter keeps JSON-RPC responses on stdout and writes only structured,
|
|
26
|
+
metadata-only diagnostics to stderr. Never put an API key in a checked-in
|
|
27
|
+
configuration file.
|
|
28
|
+
|
|
29
|
+
## Claude Desktop, Cursor, or VS Code
|
|
30
|
+
|
|
31
|
+
After the external publication and stable-URL gates pass, the intended local
|
|
32
|
+
configuration is:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"mcpServers": {
|
|
37
|
+
"simlir": {
|
|
38
|
+
"command": "simlir-mcp",
|
|
39
|
+
"env": {
|
|
40
|
+
"SIMLIR_API_KEY": "${SIMLIR_API_KEY}"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Supported tools
|
|
48
|
+
|
|
49
|
+
| Tool | API operation |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `simlir_search_products` | Semantic product search |
|
|
52
|
+
| `simlir_search_products_by_image` | Visual product search with optional natural-language context |
|
|
53
|
+
| `simlir_lookup_products_by_identifier` | Exact GTIN/MPN/SKU lookup |
|
|
54
|
+
|
|
55
|
+
Tool names, required arguments, markets, and response shapes are pinned to the
|
|
56
|
+
checked-in public contract. Update the contract first, then regenerate or
|
|
57
|
+
update both MCP adapters and their tests when the public API changes.
|
|
58
|
+
|
|
59
|
+
The image tool requires `image_url` and `market`. Its optional `query` field
|
|
60
|
+
adds shopper context such as a size, product role, compatibility requirement,
|
|
61
|
+
or use case; Simlir combines that context with the visual signal. Image-only
|
|
62
|
+
calls remain valid.
|
|
63
|
+
|
|
64
|
+
## Development
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
PYTHONPATH=../python-sdk/src:src python -m unittest discover -s tests -p 'test_*.py'
|
|
68
|
+
python -m compileall -q src
|
|
69
|
+
python -m pip wheel --no-deps --no-build-isolation --wheel-dir /tmp/simlir-python-mcp-wheel .
|
|
70
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68,<77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "simlir-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A thin stdio MCP bridge for the simlir public product API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "simlir engineering" }]
|
|
13
|
+
keywords = ["mcp", "model context protocol", "product data", "product search", "ai"]
|
|
14
|
+
classifiers = ["Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3"]
|
|
15
|
+
dependencies = ["simlir>=0.1.0,<0.2.0"]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
simlir-mcp = "simlir_mcp.server:main"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
|
|
23
|
+
[tool.simlir.release]
|
|
24
|
+
publication_enabled = true
|
|
25
|
+
contract_version = "1.0.0"
|
|
26
|
+
contract_source_sha256 = "9c0f28ec07c4208be53335568e0a86d95ebadb617dff7f37909489db382802c4"
|
|
27
|
+
publish_order = "simlir-sdk-before-simlir-mcp"
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import asdict, is_dataclass
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from simlir.errors import SimlirError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("simlir.mcp")
|
|
12
|
+
MCP_PROTOCOL_VERSION = "2025-03-26"
|
|
13
|
+
SERVER_NAME = "simlir"
|
|
14
|
+
SERVER_VERSION = "1.0.0"
|
|
15
|
+
MAX_JSON_LINE_BYTES = 1_000_000
|
|
16
|
+
|
|
17
|
+
MARKETS = ["gb", "us", "de", "fr", "es", "it", "au", "ca"]
|
|
18
|
+
|
|
19
|
+
TOOL_DEFINITIONS: tuple[dict[str, Any], ...] = (
|
|
20
|
+
{
|
|
21
|
+
"name": "simlir_search_products",
|
|
22
|
+
"description": "Search the simlir product database using a natural-language query.",
|
|
23
|
+
"inputSchema": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"query": {"type": "string", "minLength": 1, "maxLength": 1000},
|
|
27
|
+
"market": {"type": "string", "enum": MARKETS},
|
|
28
|
+
"category": {"type": "string", "maxLength": 160},
|
|
29
|
+
"brand": {"type": "string", "maxLength": 160},
|
|
30
|
+
"min_price": {"type": "number", "minimum": 0},
|
|
31
|
+
"max_price": {"type": "number", "minimum": 0},
|
|
32
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
|
|
33
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 500},
|
|
34
|
+
},
|
|
35
|
+
"required": ["query", "market"],
|
|
36
|
+
"additionalProperties": False,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "simlir_search_products_by_image",
|
|
41
|
+
"description": "Find products using a hosted HTTPS image URL, optionally guided by natural-language context or requirements.",
|
|
42
|
+
"inputSchema": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"image_url": {"type": "string", "format": "uri", "maxLength": 2048},
|
|
46
|
+
"query": {"type": "string", "minLength": 1, "maxLength": 1000},
|
|
47
|
+
"market": {"type": "string", "enum": MARKETS},
|
|
48
|
+
"category": {"type": "string", "maxLength": 160},
|
|
49
|
+
"brand": {"type": "string", "maxLength": 160},
|
|
50
|
+
"min_price": {"type": "number", "minimum": 0},
|
|
51
|
+
"max_price": {"type": "number", "minimum": 0},
|
|
52
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
|
|
53
|
+
"offset": {"type": "integer", "minimum": 0, "maximum": 500},
|
|
54
|
+
},
|
|
55
|
+
"required": ["image_url", "market"],
|
|
56
|
+
"additionalProperties": False,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "simlir_lookup_products_by_identifier",
|
|
61
|
+
"description": "Look up products by an exact GTIN, MPN/model number, or retailer SKU.",
|
|
62
|
+
"inputSchema": {
|
|
63
|
+
"type": "object",
|
|
64
|
+
"properties": {
|
|
65
|
+
"identifier": {"type": "string", "minLength": 1, "maxLength": 256},
|
|
66
|
+
"type": {"type": "string", "enum": ["auto", "gtin", "mpn", "sku"]},
|
|
67
|
+
"market": {"type": "string", "enum": MARKETS},
|
|
68
|
+
"brand": {"type": "string", "maxLength": 160},
|
|
69
|
+
"retailer": {"type": "string", "maxLength": 160},
|
|
70
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 50},
|
|
71
|
+
},
|
|
72
|
+
"required": ["identifier", "market"],
|
|
73
|
+
"additionalProperties": False,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class SimlirClientLike(Protocol):
|
|
80
|
+
def search(self, **kwargs: Any) -> Any: ...
|
|
81
|
+
|
|
82
|
+
def search_by_image(self, **kwargs: Any) -> Any: ...
|
|
83
|
+
|
|
84
|
+
def lookup(self, **kwargs: Any) -> Any: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _jsonable(value: Any) -> Any:
|
|
88
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
89
|
+
return asdict(value)
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _safe_error(error: Exception) -> dict[str, Any]:
|
|
94
|
+
if isinstance(error, SimlirError):
|
|
95
|
+
payload: dict[str, Any] = {"error": error.code, "message": error.message}
|
|
96
|
+
if error.retry_after_seconds is not None:
|
|
97
|
+
payload["retry_after_seconds"] = error.retry_after_seconds
|
|
98
|
+
if error.request_id:
|
|
99
|
+
payload["request_id"] = error.request_id
|
|
100
|
+
return payload
|
|
101
|
+
return {"error": "client_error", "message": "The simlir MCP request could not be completed."}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _tool_result(value: Any, is_error: bool = False) -> dict[str, Any]:
|
|
105
|
+
result: dict[str, Any] = {
|
|
106
|
+
"content": [{"type": "text", "text": json.dumps(_jsonable(value), separators=(",", ":"))}]
|
|
107
|
+
}
|
|
108
|
+
if is_error:
|
|
109
|
+
result["isError"] = True
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _rpc_ok(request_id: Any, result: Any) -> dict[str, Any]:
|
|
114
|
+
return {"jsonrpc": "2.0", "result": result, "id": request_id}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _rpc_error(request_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
118
|
+
return {"jsonrpc": "2.0", "error": {"code": code, "message": message, "data": None}, "id": request_id}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _arguments(value: Any) -> dict[str, Any]:
|
|
122
|
+
if value is None:
|
|
123
|
+
return {}
|
|
124
|
+
if not isinstance(value, Mapping):
|
|
125
|
+
raise SimlirError("invalid_params", "Tool arguments must be an object.")
|
|
126
|
+
return dict(value)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _only(arguments: Mapping[str, Any], allowed: set[str]) -> dict[str, Any]:
|
|
130
|
+
if set(arguments) - allowed:
|
|
131
|
+
raise SimlirError("invalid_params", "Tool arguments contain an unsupported field.")
|
|
132
|
+
return dict(arguments)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def call_tool(client: SimlirClientLike, name: str, arguments: Any) -> dict[str, Any]:
|
|
136
|
+
try:
|
|
137
|
+
values = _arguments(arguments)
|
|
138
|
+
if name == "simlir_search_products":
|
|
139
|
+
allowed = {"query", "market", "category", "brand", "min_price", "max_price", "limit", "offset"}
|
|
140
|
+
return _tool_result(client.search(**_only(values, allowed)))
|
|
141
|
+
if name == "simlir_search_products_by_image":
|
|
142
|
+
allowed = {"image_url", "query", "market", "category", "brand", "min_price", "max_price", "limit", "offset"}
|
|
143
|
+
return _tool_result(client.search_by_image(**_only(values, allowed)))
|
|
144
|
+
if name == "simlir_lookup_products_by_identifier":
|
|
145
|
+
allowed = {"identifier", "market", "type", "brand", "retailer", "limit"}
|
|
146
|
+
return _tool_result(client.lookup(**_only(values, allowed)))
|
|
147
|
+
raise SimlirError("invalid_params", f"Unknown tool: {name[:120]}.")
|
|
148
|
+
except Exception as error:
|
|
149
|
+
logger.error(
|
|
150
|
+
json.dumps(
|
|
151
|
+
{"event": "tool.call.failed", "tool": name[:120], "code": getattr(error, "code", "client_error")},
|
|
152
|
+
separators=(",", ":"),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return _tool_result(_safe_error(error), is_error=True)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class SimlirMCPServer:
|
|
159
|
+
def __init__(self, client_factory: Any):
|
|
160
|
+
self._client_factory = client_factory
|
|
161
|
+
self._client: SimlirClientLike | None = None
|
|
162
|
+
|
|
163
|
+
def _client_or_create(self) -> SimlirClientLike:
|
|
164
|
+
if self._client is None:
|
|
165
|
+
self._client = self._client_factory()
|
|
166
|
+
return self._client
|
|
167
|
+
|
|
168
|
+
def handle(self, request: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
169
|
+
request_id = request.get("id")
|
|
170
|
+
if request.get("jsonrpc") != "2.0" or not isinstance(request.get("method"), str):
|
|
171
|
+
return _rpc_error(request_id, -32600, "Invalid Request.")
|
|
172
|
+
method = request["method"]
|
|
173
|
+
if method == "initialize":
|
|
174
|
+
return _rpc_ok(
|
|
175
|
+
request_id,
|
|
176
|
+
{
|
|
177
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
178
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
179
|
+
"serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
|
|
180
|
+
},
|
|
181
|
+
)
|
|
182
|
+
if method == "notifications/initialized":
|
|
183
|
+
return None
|
|
184
|
+
if method == "tools/list":
|
|
185
|
+
return _rpc_ok(request_id, {"tools": list(TOOL_DEFINITIONS)})
|
|
186
|
+
if method == "tools/call":
|
|
187
|
+
params = request.get("params")
|
|
188
|
+
if not isinstance(params, Mapping) or not isinstance(params.get("name"), str):
|
|
189
|
+
return _rpc_error(request_id, -32602, "Tool name is required.")
|
|
190
|
+
try:
|
|
191
|
+
client = self._client_or_create()
|
|
192
|
+
except Exception as error:
|
|
193
|
+
return _rpc_ok(request_id, _tool_result(_safe_error(error), is_error=True))
|
|
194
|
+
return _rpc_ok(request_id, call_tool(client, params["name"], params.get("arguments")))
|
|
195
|
+
return _rpc_error(request_id, -32601, f"Unknown method: {method[:120]}.")
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterator, Mapping
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from simlir import Simlir
|
|
10
|
+
from simlir.errors import SimlirError
|
|
11
|
+
|
|
12
|
+
from .protocol import MAX_JSON_LINE_BYTES, SimlirMCPServer, _rpc_error
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_client_from_environment(env: Mapping[str, str] | None = None) -> Simlir:
|
|
16
|
+
values = os.environ if env is None else env
|
|
17
|
+
api_key = values.get("SIMLIR_API_KEY", "")
|
|
18
|
+
if not api_key:
|
|
19
|
+
raise SimlirError("configuration_error", "SIMLIR_API_KEY is required.")
|
|
20
|
+
kwargs: dict[str, Any] = {"api_key": api_key}
|
|
21
|
+
if values.get("SIMLIR_API_BASE_URL"):
|
|
22
|
+
kwargs["base_url"] = values["SIMLIR_API_BASE_URL"]
|
|
23
|
+
if values.get("SIMLIR_API_TIMEOUT"):
|
|
24
|
+
try:
|
|
25
|
+
kwargs["timeout"] = float(values["SIMLIR_API_TIMEOUT"])
|
|
26
|
+
except ValueError as error:
|
|
27
|
+
raise SimlirError("configuration_error", "SIMLIR_API_TIMEOUT must be numeric.") from error
|
|
28
|
+
if values.get("SIMLIR_API_MAX_RETRIES"):
|
|
29
|
+
try:
|
|
30
|
+
kwargs["max_retries"] = int(values["SIMLIR_API_MAX_RETRIES"])
|
|
31
|
+
except ValueError as error:
|
|
32
|
+
raise SimlirError("configuration_error", "SIMLIR_API_MAX_RETRIES must be an integer.") from error
|
|
33
|
+
return Simlir(**kwargs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def handle_message(server: SimlirMCPServer, raw: str) -> dict[str, Any] | None:
|
|
37
|
+
try:
|
|
38
|
+
request = json.loads(raw)
|
|
39
|
+
except json.JSONDecodeError:
|
|
40
|
+
return _rpc_error(None, -32700, "Parse error.")
|
|
41
|
+
if not isinstance(request, Mapping):
|
|
42
|
+
return _rpc_error(None, -32600, "Invalid Request.")
|
|
43
|
+
return server.handle(request)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _lines(stream: Any) -> Iterator[str]:
|
|
47
|
+
for raw in stream:
|
|
48
|
+
if len(raw.encode("utf-8")) > MAX_JSON_LINE_BYTES:
|
|
49
|
+
yield "{\"jsonrpc\":\"2.0\",\"method\":\"__oversize__\"}"
|
|
50
|
+
else:
|
|
51
|
+
yield raw
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_stdio(server: SimlirMCPServer | None = None, input_stream: Any = None, output_stream: Any = None) -> None:
|
|
55
|
+
active_server = server or SimlirMCPServer(create_client_from_environment)
|
|
56
|
+
input_value = input_stream or sys.stdin
|
|
57
|
+
output_value = output_stream or sys.stdout
|
|
58
|
+
for raw in _lines(input_value):
|
|
59
|
+
response = handle_message(active_server, raw)
|
|
60
|
+
if response is not None:
|
|
61
|
+
output_value.write(json.dumps(response, separators=(",", ":")) + "\n")
|
|
62
|
+
output_value.flush()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> None:
|
|
66
|
+
run_stdio()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: simlir-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A thin stdio MCP bridge for the simlir public product API
|
|
5
|
+
Author: simlir engineering
|
|
6
|
+
License: UNLICENSED — publication and legal licensing gate pending.
|
|
7
|
+
|
|
8
|
+
Keywords: mcp,model context protocol,product data,product search,ai
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: simlir<0.2.0,>=0.1.0
|
|
16
|
+
|
|
17
|
+
# simlir-mcp
|
|
18
|
+
|
|
19
|
+
Release candidate for a thin Python stdio adapter around the simlir public
|
|
20
|
+
product API. It is intentionally separate from Simlir’s internal
|
|
21
|
+
repository-intelligence MCP server and contains no ingestion code, customer
|
|
22
|
+
data, database credentials, or internal repository tooling.
|
|
23
|
+
|
|
24
|
+
The PyPI publication workflow is configured, but the package remains
|
|
25
|
+
unpublished pending legal licensing, public repository permissions, an assigned
|
|
26
|
+
engineering-maintainer role, PyPI name/policy checks, stable API deployment,
|
|
27
|
+
and release rollback evidence. Do not use the package name from a public
|
|
28
|
+
registry until those gates are recorded.
|
|
29
|
+
|
|
30
|
+
## Local use
|
|
31
|
+
|
|
32
|
+
Install the SDK and this adapter from the local release candidates:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
python -m pip install --no-deps ../python-sdk
|
|
36
|
+
python -m pip install --no-deps .
|
|
37
|
+
export SIMLIR_API_KEY=sk_your_key_here
|
|
38
|
+
python -m simlir_mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The adapter keeps JSON-RPC responses on stdout and writes only structured,
|
|
42
|
+
metadata-only diagnostics to stderr. Never put an API key in a checked-in
|
|
43
|
+
configuration file.
|
|
44
|
+
|
|
45
|
+
## Claude Desktop, Cursor, or VS Code
|
|
46
|
+
|
|
47
|
+
After the external publication and stable-URL gates pass, the intended local
|
|
48
|
+
configuration is:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"simlir": {
|
|
54
|
+
"command": "simlir-mcp",
|
|
55
|
+
"env": {
|
|
56
|
+
"SIMLIR_API_KEY": "${SIMLIR_API_KEY}"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Supported tools
|
|
64
|
+
|
|
65
|
+
| Tool | API operation |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `simlir_search_products` | Semantic product search |
|
|
68
|
+
| `simlir_search_products_by_image` | Visual product search with optional natural-language context |
|
|
69
|
+
| `simlir_lookup_products_by_identifier` | Exact GTIN/MPN/SKU lookup |
|
|
70
|
+
|
|
71
|
+
Tool names, required arguments, markets, and response shapes are pinned to the
|
|
72
|
+
checked-in public contract. Update the contract first, then regenerate or
|
|
73
|
+
update both MCP adapters and their tests when the public API changes.
|
|
74
|
+
|
|
75
|
+
The image tool requires `image_url` and `market`. Its optional `query` field
|
|
76
|
+
adds shopper context such as a size, product role, compatibility requirement,
|
|
77
|
+
or use case; Simlir combines that context with the visual signal. Image-only
|
|
78
|
+
calls remain valid.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
PYTHONPATH=../python-sdk/src:src python -m unittest discover -s tests -p 'test_*.py'
|
|
84
|
+
python -m compileall -q src
|
|
85
|
+
python -m pip wheel --no-deps --no-build-isolation --wheel-dir /tmp/simlir-python-mcp-wheel .
|
|
86
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/simlir_mcp/__init__.py
|
|
5
|
+
src/simlir_mcp/__main__.py
|
|
6
|
+
src/simlir_mcp/protocol.py
|
|
7
|
+
src/simlir_mcp/server.py
|
|
8
|
+
src/simlir_mcp.egg-info/PKG-INFO
|
|
9
|
+
src/simlir_mcp.egg-info/SOURCES.txt
|
|
10
|
+
src/simlir_mcp.egg-info/dependency_links.txt
|
|
11
|
+
src/simlir_mcp.egg-info/entry_points.txt
|
|
12
|
+
src/simlir_mcp.egg-info/requires.txt
|
|
13
|
+
src/simlir_mcp.egg-info/top_level.txt
|
|
14
|
+
tests/test_pypi_metadata.spec.py
|
|
15
|
+
tests/test_server.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simlir<0.2.0,>=0.1.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simlir_mcp
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PythonMcpPyPiMetadataTests(unittest.TestCase):
|
|
11
|
+
def test_publication_metadata_matches_configured_workflow(self) -> None:
|
|
12
|
+
pyproject = (ROOT / "developer-channels/python-mcp/pyproject.toml").read_text()
|
|
13
|
+
workflow = (ROOT / ".github/workflows/publish-python-mcp.yml").read_text()
|
|
14
|
+
|
|
15
|
+
self.assertIn('name = "simlir-mcp"', pyproject)
|
|
16
|
+
self.assertIn('requires = ["setuptools>=68,<77"]', pyproject)
|
|
17
|
+
self.assertIn("publication_enabled = true", pyproject)
|
|
18
|
+
self.assertIn("workflow_dispatch:", workflow)
|
|
19
|
+
self.assertIn("pypa/gh-action-pypi-publish@", workflow)
|
|
20
|
+
self.assertIn("name: pypi", workflow)
|
|
21
|
+
self.assertIn("Verify Python MCP distribution metadata", workflow)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
unittest.main()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from simlir_mcp.protocol import MCP_PROTOCOL_VERSION, TOOL_DEFINITIONS
|
|
9
|
+
from simlir_mcp.server import SimlirMCPServer, create_client_from_environment, handle_message, run_stdio
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeClient:
|
|
13
|
+
def search(self, **kwargs):
|
|
14
|
+
return {"operation": "search", "arguments": kwargs}
|
|
15
|
+
|
|
16
|
+
def search_by_image(self, **kwargs):
|
|
17
|
+
return {"operation": "image", "arguments": kwargs}
|
|
18
|
+
|
|
19
|
+
def lookup(self, **kwargs):
|
|
20
|
+
return {"operation": "lookup", "arguments": kwargs}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PythonMcpServerTests(unittest.TestCase):
|
|
24
|
+
def setUp(self):
|
|
25
|
+
self.server = SimlirMCPServer(lambda: FakeClient())
|
|
26
|
+
|
|
27
|
+
def test_initialize_and_tools_list_match_contract(self):
|
|
28
|
+
initialize = self.server.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize"})
|
|
29
|
+
self.assertEqual(initialize["result"]["protocolVersion"], MCP_PROTOCOL_VERSION)
|
|
30
|
+
self.assertEqual([tool["name"] for tool in TOOL_DEFINITIONS], [
|
|
31
|
+
"simlir_search_products",
|
|
32
|
+
"simlir_search_products_by_image",
|
|
33
|
+
"simlir_lookup_products_by_identifier",
|
|
34
|
+
])
|
|
35
|
+
listed = self.server.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
|
36
|
+
self.assertEqual(listed["result"]["tools"], list(TOOL_DEFINITIONS))
|
|
37
|
+
|
|
38
|
+
def test_routes_all_tools_without_exposing_transport(self):
|
|
39
|
+
cases = [
|
|
40
|
+
("simlir_search_products", {"query": "headphones", "market": "gb"}, "search"),
|
|
41
|
+
("simlir_search_products_by_image", {"image_url": "https://example.com/item.jpg", "market": "gb", "query": "small flat"}, "image"),
|
|
42
|
+
("simlir_lookup_products_by_identifier", {"identifier": "123", "market": "gb"}, "lookup"),
|
|
43
|
+
]
|
|
44
|
+
for name, arguments, operation in cases:
|
|
45
|
+
response = self.server.handle({"jsonrpc": "2.0", "id": name, "method": "tools/call", "params": {"name": name, "arguments": arguments}})
|
|
46
|
+
payload = json.loads(response["result"]["content"][0]["text"])
|
|
47
|
+
self.assertEqual(payload["operation"], operation)
|
|
48
|
+
self.assertEqual(payload["arguments"], arguments)
|
|
49
|
+
|
|
50
|
+
def test_errors_are_safe_and_stdio_keeps_notifications_off_stdout(self):
|
|
51
|
+
unknown = self.server.handle({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "not_a_tool", "arguments": {}}})
|
|
52
|
+
self.assertTrue(unknown["result"]["isError"])
|
|
53
|
+
self.assertNotIn("sk_", json.dumps(unknown))
|
|
54
|
+
|
|
55
|
+
output = io.StringIO()
|
|
56
|
+
run_stdio(self.server, io.StringIO('{"jsonrpc":"2.0","method":"notifications/initialized"}\n{"jsonrpc":"2.0","id":4,"method":"tools/list"}\n'), output)
|
|
57
|
+
lines = output.getvalue().splitlines()
|
|
58
|
+
self.assertEqual(len(lines), 1)
|
|
59
|
+
self.assertEqual(json.loads(lines[0])["id"], 4)
|
|
60
|
+
|
|
61
|
+
def test_missing_configuration_is_a_safe_tool_error(self):
|
|
62
|
+
server = SimlirMCPServer(lambda: create_client_from_environment({}))
|
|
63
|
+
response = server.handle({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "simlir_search_products", "arguments": {}}})
|
|
64
|
+
self.assertTrue(response["result"]["isError"])
|
|
65
|
+
payload = json.loads(response["result"]["content"][0]["text"])
|
|
66
|
+
self.assertEqual(payload["error"], "configuration_error")
|
|
67
|
+
self.assertNotIn("sk_", json.dumps(response))
|
|
68
|
+
|
|
69
|
+
def test_invalid_json_and_oversized_input_are_bounded(self):
|
|
70
|
+
invalid = handle_message(self.server, "not json")
|
|
71
|
+
self.assertEqual(invalid["error"]["code"], -32700)
|
|
72
|
+
output = io.StringIO()
|
|
73
|
+
run_stdio(self.server, io.StringIO("x" * 1_000_001 + "\n"), output)
|
|
74
|
+
self.assertEqual(json.loads(output.getvalue())["error"]["code"], -32601)
|
|
75
|
+
|
|
76
|
+
def test_pypi_build_uses_compatible_metadata_backend(self):
|
|
77
|
+
root = pathlib.Path(__file__).resolve().parents[3]
|
|
78
|
+
pyproject = (root / "developer-channels/python-mcp/pyproject.toml").read_text()
|
|
79
|
+
workflow = (root / ".github/workflows/publish-python-mcp.yml").read_text()
|
|
80
|
+
self.assertIn('requires = ["setuptools>=68,<77"]', pyproject)
|
|
81
|
+
self.assertIn("Verify Python MCP distribution metadata", workflow)
|
|
82
|
+
self.assertIn('metadata.get("Name") != "simlir-mcp"', workflow)
|
|
83
|
+
self.assertIn('metadata.get("Version") != "0.1.0"', workflow)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
unittest.main()
|