lcp-mcp-server 1.0.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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.5
2
+ Name: lcp-mcp-server
3
+ Version: 1.0.0
4
+ Summary: LCP (Lead Context Protocol) MCP server — thin adapter wrapping LCP REST endpoints as agent tools.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.27.0
8
+ Requires-Dist: jsonschema>=4.20.0
9
+ Requires-Dist: lcp-sdk>=1.0.0
10
+ Requires-Dist: mcp<2.1.0,>=1.28.1
11
+ Requires-Dist: referencing>=0.35.0
@@ -0,0 +1,91 @@
1
+ # LCP MCP Server — Reference Implementation
2
+
3
+ A thin MCP (Model Context Protocol) adapter that exposes LCP REST endpoints
4
+ as agent tools. The core protocol is REST+JSON; this server wraps it so AI
5
+ agents can interact with any LCP-compliant endpoint via MCP. It does not
6
+ host an LCP platform, persist leads, receive webhooks, or run auctions.
7
+
8
+ > **Architecture:** The LCP core never depends on MCP. This server is a
9
+ > stateless adapter — it translates MCP tool calls into HTTP requests and
10
+ > HTTP responses into MCP tool results. It works with any LCP-compliant
11
+ > endpoint (any LCP-compliant REST API).
12
+
13
+ ## Tools
14
+
15
+ | Tool | LCP Endpoint | Description |
16
+ |---|---|---|
17
+ | `submit_lead` | `POST /v1/lcp/leads` | Submit a lead message |
18
+ | `submit_call` | `POST /v1/lcp/calls` | Submit a call lead message |
19
+ | `query_lead_status` | `GET /v1/lcp/leads/{lead_id}` | Query lead status and lifecycle |
20
+ | `get_schema` | `GET /v1/lcp/schemas/{name}` | Retrieve a JSON Schema (envelope, core, message type, offer, vertical) |
21
+ | `get_capabilities` | `GET /v1/lcp/capabilities` | Discover endpoint capabilities (versions, verticals, countries) |
22
+ | `list_offers` | `GET /v1/lcp/offers` | List active offers for the authenticated sender |
23
+ | `submit_bid` | `POST /v1/lcp/bids` | Submit a bid in response to a ping |
24
+
25
+ ## Setup
26
+
27
+ ```bash
28
+ # From the repository root while packages are unreleased.
29
+ python3 -m pip install -e implementations/sdk/python -e implementations/mcp-server
30
+
31
+ # After the packages are published, install the adapter and its shared SDK.
32
+ python3 -m pip install lcp-mcp-server lcp-sdk
33
+ ```
34
+
35
+ The adapter delegates HTTP, HMAC signing, retries, and raw-body security
36
+ primitives to `lcp-sdk`; it only provides the MCP tool binding and local schema
37
+ fallback.
38
+
39
+ ## Configuration
40
+
41
+ The server reads its target endpoint from environment variables:
42
+
43
+ | Variable | Default | Description |
44
+ |---|---|---|
45
+ | `LCP_ENDPOINT` | `http://localhost:8000` | Base URL of the LCP-compliant REST API |
46
+ | `LCP_API_KEY` | (none) | Bearer token for authentication |
47
+ | `LCP_HMAC_SECRET` | (none) | HMAC shared secret (if using HMAC auth instead of API key) |
48
+ | `LCP_SENDER_ID` | (none) | Sender ID to use in outgoing messages and read requests |
49
+
50
+ ## Authentication profile
51
+
52
+ HMAC requests use the canonical signing input:
53
+
54
+ ```text
55
+ <timestamp>\n<idempotency-key>\n<raw-request-body>
56
+ ```
57
+
58
+ The adapter sends `X-LCP-Sender-Id`, `X-LCP-Timestamp`,
59
+ `X-LCP-Idempotency-Key` for mutating requests, and `X-LCP-Signature`. Bearer
60
+ requests use `Authorization: Bearer <token>`.
61
+
62
+ ## Run
63
+
64
+ ```bash
65
+ # Stdio transport (for MCP clients like Claude Desktop, Hermes)
66
+ lcp-mcp-server
67
+
68
+ # Or via Python module
69
+ python -m lcp_mcp_server.server
70
+ ```
71
+
72
+ ## MCP Client Configuration
73
+
74
+ Add to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`):
75
+
76
+ ```json
77
+ {
78
+ "mcpServers": {
79
+ "lcp": {
80
+ "command": "lcp-mcp-server",
81
+ "env": {
82
+ "LCP_ENDPOINT": "https://api.example.com",
83
+ "LCP_API_KEY": "your-api-key",
84
+ "LCP_SENDER_ID": "your-sender-id"
85
+ }
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ ## LicenseApache 2.0 — same as the LCP specification. See the [SDK support policy](../../docs/SDK-ROADMAP.md) for the MCP relationship and package compatibility.
@@ -0,0 +1,5 @@
1
+ """LCP MCP Server package."""
2
+
3
+ from .server import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,87 @@
1
+ """LCP HTTP client compatibility wrapper for the MCP adapter.
2
+
3
+ The MCP package intentionally delegates authentication, retries, and HTTP
4
+ transport to the standalone lcp-sdk package. This wrapper preserves the MCP
5
+ adapter's historical ``post``/``get`` response shape, including ``_ok`` and
6
+ ``_status_code`` metadata.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Any
13
+
14
+ from lcp_sdk import LCPClient as SDKLCPClient
15
+ from lcp_sdk import LCPHTTPError
16
+
17
+
18
+ class LCPClient:
19
+ """MCP-facing compatibility wrapper around :class:`lcp_sdk.LCPClient`."""
20
+
21
+ def __init__(
22
+ self,
23
+ endpoint: str | None = None,
24
+ api_key: str | None = None,
25
+ hmac_secret: str | None = None,
26
+ sender_id: str | None = None,
27
+ timeout: float = 30.0,
28
+ ) -> None:
29
+ self._client = SDKLCPClient(
30
+ endpoint or os.environ.get("LCP_ENDPOINT", "http://localhost:8000"),
31
+ sender_id=sender_id or os.environ.get("LCP_SENDER_ID", ""),
32
+ api_key=api_key or os.environ.get("LCP_API_KEY"),
33
+ hmac_secret=hmac_secret or os.environ.get("LCP_HMAC_SECRET"),
34
+ timeout=timeout,
35
+ )
36
+
37
+ @property
38
+ def endpoint(self) -> str:
39
+ return self._client.endpoint
40
+
41
+ @property
42
+ def sender_id(self) -> str | None:
43
+ return self._client.sender_id
44
+
45
+ def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
46
+ """POST an LCP envelope, preserving the legacy MCP response shape."""
47
+ try:
48
+ data = self._client.request("POST", path, payload=payload)
49
+ return _with_metadata(data, 200, True)
50
+ except LCPHTTPError as exc:
51
+ body = exc.body if isinstance(exc.body, dict) else {"raw": exc.body}
52
+ return _with_metadata(body, exc.status_code, False)
53
+
54
+ def get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
55
+ """GET from the LCP endpoint, preserving the legacy response shape."""
56
+ try:
57
+ data = self._client.request("GET", path, params=params)
58
+ return _with_metadata(data, 200, True)
59
+ except LCPHTTPError as exc:
60
+ body = exc.body if isinstance(exc.body, dict) else {"raw": exc.body}
61
+ return _with_metadata(body, exc.status_code, False)
62
+
63
+ def submit_lead(self, envelope: dict[str, Any]) -> dict[str, Any]:
64
+ return self.post("/v1/lcp/leads", envelope)
65
+
66
+ def submit_call(self, envelope: dict[str, Any]) -> dict[str, Any]:
67
+ return self.post("/v1/lcp/calls", envelope)
68
+
69
+ def query_lead_status(self, lead_id: str) -> dict[str, Any]:
70
+ return self.get(f"/v1/lcp/leads/{lead_id}")
71
+
72
+ def get_schema(self, name: str) -> dict[str, Any]:
73
+ return self.get(f"/v1/lcp/schemas/{name}")
74
+
75
+ def get_capabilities(self) -> dict[str, Any]:
76
+ return self.get("/v1/lcp/capabilities")
77
+
78
+ def list_offers(self, vertical: str | None = None) -> dict[str, Any]:
79
+ params = {"vertical": vertical} if vertical else None
80
+ return self.get("/v1/lcp/offers", params=params)
81
+
82
+
83
+ def _with_metadata(data: dict[str, Any], status_code: int, ok: bool) -> dict[str, Any]:
84
+ result = dict(data)
85
+ result["_status_code"] = status_code
86
+ result["_ok"] = ok
87
+ return result
@@ -0,0 +1,47 @@
1
+ """Schema loader — reads JSON Schemas from the LCP repo.
2
+
3
+ When the REST endpoint is unavailable (e.g. local development), the MCP server
4
+ can serve schemas directly from the repo's schemas/ and verticals/ directories.
5
+
6
+ LCP — Lead Context Protocol. Created by Spear Systems (a Spear company).
7
+ Open standard — Apache 2.0, free to implement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ # implementations/mcp-server/ -> ../../schemas, ../../verticals
17
+ _REPO_ROOT = Path(__file__).resolve().parents[3]
18
+ SCHEMAS_DIR = _REPO_ROOT / "schemas"
19
+ VERTICALS_DIR = _REPO_ROOT / "verticals"
20
+
21
+
22
+ def list_schemas() -> list[str]:
23
+ """List all available schema names."""
24
+ names: list[str] = []
25
+ if SCHEMAS_DIR.exists():
26
+ names.extend(f.stem for f in SCHEMAS_DIR.glob("*.json"))
27
+ if VERTICALS_DIR.exists():
28
+ names.extend(f"verticals/{f.stem}" for f in VERTICALS_DIR.glob("*.json"))
29
+ return sorted(names)
30
+
31
+
32
+ def load_schema(name: str) -> dict[str, Any] | None:
33
+ """Load a schema by name. Returns None if not found.
34
+
35
+ Core/message schemas: name -> schemas/{name}.json
36
+ Vertical schemas: verticals/{name} -> verticals/{name}.json
37
+ """
38
+ if name.startswith("verticals/"):
39
+ path = VERTICALS_DIR / f"{name.removeprefix('verticals/')}.json"
40
+ else:
41
+ path = SCHEMAS_DIR / f"{name}.json"
42
+
43
+ if not path.exists():
44
+ return None
45
+
46
+ with open(path) as f:
47
+ return json.load(f)
@@ -0,0 +1,258 @@
1
+ """LCP MCP Server — thin adapter wrapping LCP REST endpoints as MCP tools.
2
+
3
+ Tools map 1:1 to LCP REST endpoints per SPEC.md §10:
4
+ submit_lead -> POST /v1/lcp/leads
5
+ submit_call -> POST /v1/lcp/calls
6
+ query_lead_status -> GET /v1/lcp/leads/{lead_id}
7
+ get_schema -> GET /v1/lcp/schemas/{name} (falls back to local repo)
8
+ get_capabilities -> GET /v1/lcp/capabilities
9
+ list_offers -> GET /v1/lcp/offers
10
+
11
+ The server is stateless — it creates a fresh HTTP client per tool call.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import Any
18
+
19
+ import mcp.types as types
20
+ from mcp.server import Server
21
+ from mcp.server.stdio import stdio_server
22
+ from lcp_sdk import build_envelope
23
+
24
+ from .client import LCPClient
25
+ from .schema_loader import list_schemas, load_schema
26
+
27
+ app: Server
28
+
29
+ def _make_envelope(msg_type: str, sender_id: str, receiver_id: str, payload: dict[str, Any]) -> dict[str, Any]:
30
+ """Wrap a payload using the shared Python SDK envelope builder."""
31
+ return build_envelope(msg_type, sender_id, receiver_id, payload)
32
+
33
+
34
+ def _tool_result(data: dict[str, Any]) -> list[types.TextContent]:
35
+ """Format a dict as an MCP text content response."""
36
+ return [types.TextContent(type="text", text=json.dumps(data, indent=2))]
37
+
38
+
39
+ def _error_result(message: str, code: str = "LCP-500") -> list[types.TextContent]:
40
+ """Format an error as an MCP text content response."""
41
+ return _tool_result({"errors": [{"code": code, "message": message}]})
42
+
43
+
44
+ # ─── Tool definitions ────────────────────────────────────────────────────────
45
+
46
+ async def _list_tools() -> list[types.Tool]:
47
+ return [
48
+ types.Tool(
49
+ name="submit_lead",
50
+ description="Submit a lead to an LCP-compliant endpoint. The payload should contain lead fields (consumer, location, compliance, attributes, channel, etc.). The server wraps it in an LCP envelope.",
51
+ inputSchema={
52
+ "type": "object",
53
+ "properties": {
54
+ "receiver_id": {"type": "string", "description": "LCP receiver ID (e.g. 'spx')."},
55
+ "payload": {
56
+ "type": "object",
57
+ "description": "Lead payload — consumer, location, compliance, provenance, attributes, channel, etc. See LCP lead schema.",
58
+ },
59
+ },
60
+ "required": ["receiver_id", "payload"],
61
+ },
62
+ ),
63
+ types.Tool(
64
+ name="submit_call",
65
+ description="Submit a call lead to an LCP-compliant endpoint. The payload should contain call fields (call block, consumer, location, compliance, attributes).",
66
+ inputSchema={
67
+ "type": "object",
68
+ "properties": {
69
+ "receiver_id": {"type": "string", "description": "LCP receiver ID."},
70
+ "payload": {
71
+ "type": "object",
72
+ "description": "Call payload — call block (call_id, status, durations, recording, etc.), consumer, location, compliance, attributes. See LCP call schema.",
73
+ },
74
+ },
75
+ "required": ["receiver_id", "payload"],
76
+ },
77
+ ),
78
+ types.Tool(
79
+ name="query_lead_status",
80
+ description="Query the status and lifecycle of a previously submitted lead.",
81
+ inputSchema={
82
+ "type": "object",
83
+ "properties": {
84
+ "lead_id": {"type": "string", "description": "The lead ID to query."},
85
+ },
86
+ "required": ["lead_id"],
87
+ },
88
+ ),
89
+ types.Tool(
90
+ name="get_schema",
91
+ description="Retrieve a JSON Schema by name. Core schemas: envelope, core, lead, call, ping, post, ack, event, bid, offer. Vertical schemas: 'verticals/mortgage', etc. Falls back to local repo files if the REST endpoint is unavailable.",
92
+ inputSchema={
93
+ "type": "object",
94
+ "properties": {
95
+ "name": {
96
+ "type": "string",
97
+ "description": "Schema name (e.g. 'envelope', 'lead', 'verticals/mortgage').",
98
+ },
99
+ },
100
+ "required": ["name"],
101
+ },
102
+ ),
103
+ types.Tool(
104
+ name="get_capabilities",
105
+ description="Discover the capabilities of the LCP endpoint — supported versions, message types, verticals, countries, auth methods, events, conformance level.",
106
+ inputSchema={
107
+ "type": "object",
108
+ "properties": {},
109
+ },
110
+ ),
111
+ types.Tool(
112
+ name="list_offers",
113
+ description="List active offers available to the authenticated sender. Optionally filter by vertical.",
114
+ inputSchema={
115
+ "type": "object",
116
+ "properties": {
117
+ "vertical": {
118
+ "type": "string",
119
+ "description": "Filter offers by vertical (e.g. 'mortgage').",
120
+ },
121
+ },
122
+ },
123
+ ),
124
+ types.Tool(
125
+ name="submit_bid",
126
+ description="Submit a bid in response to a ping. The platform collects all bids and selects the winner. The winner receives a post with full PII.",
127
+ inputSchema={
128
+ "type": "object",
129
+ "properties": {
130
+ "ping_id": {"type": "string", "description": "ID of the ping being responded to."},
131
+ "decision": {"type": "string", "enum": ["accept", "reject", "pass"], "description": "accept = bid for the lead; reject = do not want; pass = decline to bid."},
132
+ "bid_price_cents": {"type": "integer", "minimum": 0, "description": "Bid price in cents. Required when decision = accept."},
133
+ "currency": {"type": "string", "description": "ISO 4217 currency code (e.g. AUD, USD)."},
134
+ "estimated_contact_seconds": {"type": "integer", "minimum": 0, "description": "Estimated time-to-contact in seconds."},
135
+ "buyer_reference": {"type": "string", "description": "Buyer's internal tracking reference."},
136
+ "reject_reason": {"type": "string", "description": "Why rejected (optional)."},
137
+ },
138
+ "required": ["ping_id", "decision"],
139
+ },
140
+ ),
141
+ ]
142
+
143
+
144
+ # ─── Tool call handler ───────────────────────────────────────────────────────
145
+
146
+ async def _call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
147
+ client = LCPClient()
148
+
149
+ if name == "submit_lead":
150
+ receiver_id = arguments.get("receiver_id", "")
151
+ payload = arguments.get("payload", {})
152
+ envelope = _make_envelope("lead", client.sender_id, receiver_id, payload)
153
+ result = client.submit_lead(envelope)
154
+ return _tool_result(result)
155
+
156
+ elif name == "submit_call":
157
+ receiver_id = arguments.get("receiver_id", "")
158
+ payload = arguments.get("payload", {})
159
+ envelope = _make_envelope("call", client.sender_id, receiver_id, payload)
160
+ result = client.submit_call(envelope)
161
+ return _tool_result(result)
162
+
163
+ elif name == "query_lead_status":
164
+ lead_id = arguments.get("lead_id", "")
165
+ result = client.query_lead_status(lead_id)
166
+ return _tool_result(result)
167
+
168
+ elif name == "get_schema":
169
+ schema_name = arguments.get("name", "")
170
+ # Try the REST endpoint first
171
+ try:
172
+ result = client.get_schema(schema_name)
173
+ if result.get("_ok"):
174
+ return _tool_result(result)
175
+ except Exception:
176
+ pass # Fall back to local repo
177
+ # Fall back to local repo files
178
+ schema = load_schema(schema_name)
179
+ if schema:
180
+ return _tool_result(schema)
181
+ available = list_schemas()
182
+ return _error_result(
183
+ f"Schema '{schema_name}' not found. Available: {', '.join(available)}",
184
+ code="LCP-007",
185
+ )
186
+
187
+ elif name == "get_capabilities":
188
+ result = client.get_capabilities()
189
+ return _tool_result(result)
190
+
191
+ elif name == "list_offers":
192
+ vertical = arguments.get("vertical")
193
+ result = client.list_offers(vertical)
194
+ return _tool_result(result)
195
+
196
+ elif name == "submit_bid":
197
+ ping_id = arguments.get("ping_id", "")
198
+ decision = arguments.get("decision", "pass")
199
+ bid_price = arguments.get("bid_price_cents", 0)
200
+ currency = arguments.get("currency", "USD")
201
+ payload = {
202
+ "ping_id": ping_id,
203
+ "decision": decision,
204
+ "bid_price_cents": bid_price,
205
+ "currency": currency,
206
+ }
207
+ for opt_field in ["estimated_contact_seconds", "buyer_reference", "reject_reason"]:
208
+ if opt_field in arguments:
209
+ payload[opt_field] = arguments[opt_field]
210
+ receiver_id = "platform" # bids go to the platform, not a buyer
211
+ envelope = _make_envelope("bid", client.sender_id, receiver_id, payload)
212
+ result = client.post("/v1/lcp/bids", envelope)
213
+ return _tool_result(result)
214
+
215
+ else:
216
+ return _error_result(f"Unknown tool: {name}", code="LCP-006")
217
+
218
+
219
+ # ─── MCP SDK compatibility ───────────────────────────────────────────────────
220
+
221
+ # MCP 1.x exposed decorator methods on Server; MCP 2.x accepts callbacks in
222
+ # the constructor. Keep the adapter compatible with both supported API shapes
223
+ # while the dependency range transitions to the 2.x server implementation.
224
+ if hasattr(Server, "list_tools"):
225
+ legacy_app = Server("lcp-mcp-server")
226
+ list_tools = legacy_app.list_tools()(_list_tools)
227
+ call_tool = legacy_app.call_tool()(_call_tool)
228
+ app = legacy_app
229
+ else:
230
+ async def list_tools(_context: Any, _params: Any) -> types.ListToolsResult:
231
+ return types.ListToolsResult(tools=await _list_tools())
232
+
233
+ async def call_tool(_context: Any, params: types.CallToolRequestParams) -> types.CallToolResult:
234
+ content = await _call_tool(params.name, params.arguments or {})
235
+ return types.CallToolResult(content=content)
236
+
237
+ app = Server(
238
+ "lcp-mcp-server",
239
+ on_list_tools=list_tools,
240
+ on_call_tool=call_tool,
241
+ )
242
+
243
+
244
+ # ─── Entrypoint ──────────────────────────────────────────────────────────────
245
+
246
+ async def _run() -> None:
247
+ async with stdio_server() as (read_stream, write_stream):
248
+ await app.run(read_stream, write_stream, app.create_initialization_options())
249
+
250
+
251
+ def main() -> None:
252
+ """Entrypoint for the lcp-mcp-server command."""
253
+ import asyncio
254
+ asyncio.run(_run())
255
+
256
+
257
+ if __name__ == "__main__":
258
+ main()
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "lcp-mcp-server"
3
+ version = "1.0.0"
4
+ description = "LCP (Lead Context Protocol) MCP server — thin adapter wrapping LCP REST endpoints as agent tools."
5
+ license = "Apache-2.0"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "mcp>=1.28.1,<2.1.0",
9
+ "lcp-sdk>=1.0.0",
10
+ "httpx>=0.27.0",
11
+ "jsonschema>=4.20.0",
12
+ "referencing>=0.35.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ lcp-mcp-server = "lcp_mcp_server.server:main"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["lcp_mcp_server"]