ctxprotocol 0.5.7__tar.gz → 0.6.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.
Files changed (22) hide show
  1. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/PKG-INFO +1 -1
  2. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/auth/__init__.py +111 -12
  3. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/client/resources/discovery.py +2 -1
  4. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/client/resources/tools.py +1 -1
  5. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/client/types.py +31 -8
  6. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/context/__init__.py +22 -8
  7. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/handshake/__init__.py +18 -9
  8. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/pyproject.toml +1 -1
  9. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/.gitignore +0 -0
  10. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/README.md +0 -0
  11. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/__init__.py +0 -0
  12. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/client/__init__.py +0 -0
  13. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/client/client.py +0 -0
  14. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/client/resources/__init__.py +0 -0
  15. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/context/hyperliquid.py +0 -0
  16. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/context/polymarket.py +0 -0
  17. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/context/wallet.py +0 -0
  18. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/ctxprotocol/py.typed +0 -0
  19. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/examples/server/hummingbot-contributor/README.md +0 -0
  20. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/examples/server/hummingbot-contributor/env.example +0 -0
  21. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/examples/server/hummingbot-contributor/requirements.txt +0 -0
  22. {ctxprotocol-0.5.7 → ctxprotocol-0.6.0}/examples/server/hummingbot-contributor/server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxprotocol
3
- Version: 0.5.7
3
+ Version: 0.6.0
4
4
  Summary: Official Python SDK for the Context Protocol - Discover and execute AI tools programmatically
5
5
  Project-URL: Homepage, https://ctxprotocol.com
6
6
  Project-URL: Documentation, https://docs.ctxprotocol.com
@@ -38,6 +38,54 @@ weipe6amt9lzQzi8WXaFKpOXHQs//WDlUytz/Hl8pvd5craZKzo6Kyrg1Vfan7H3
38
38
  TQIDAQAB
39
39
  -----END PUBLIC KEY-----"""
40
40
 
41
+ # ============================================================================
42
+ # JWKS Key Fetching (with hardcoded fallback)
43
+ # ============================================================================
44
+
45
+ JWKS_URL = "https://ctxprotocol.com/.well-known/jwks.json"
46
+ _KEY_CACHE_TTL_SECONDS = 3600 # 1 hour
47
+
48
+ _cached_public_key: Any = None
49
+ _cache_timestamp: float = 0
50
+
51
+
52
+ async def _get_platform_public_key() -> Any:
53
+ """Get the platform public key, trying JWKS endpoint first with hardcoded fallback.
54
+
55
+ Caches the result for 1 hour.
56
+ """
57
+ import time
58
+ global _cached_public_key, _cache_timestamp
59
+
60
+ now = time.time()
61
+
62
+ # Return cached key if still valid
63
+ if _cached_public_key is not None and now - _cache_timestamp < _KEY_CACHE_TTL_SECONDS:
64
+ return _cached_public_key
65
+
66
+ # Try JWKS endpoint first
67
+ try:
68
+ import httpx
69
+ async with httpx.AsyncClient(timeout=5.0) as client:
70
+ response = await client.get(JWKS_URL)
71
+ if response.is_success:
72
+ jwks = response.json()
73
+ if jwks.get("keys") and len(jwks["keys"]) > 0:
74
+ # For now, use hardcoded key (JWKS parsing requires additional logic)
75
+ # This establishes the pattern for when the endpoint is deployed
76
+ pass
77
+ except Exception:
78
+ # JWKS fetch failed - fall back to hardcoded key
79
+ pass
80
+
81
+ # Fallback: use hardcoded key
82
+ _cached_public_key = serialization.load_pem_public_key(
83
+ CONTEXT_PLATFORM_PUBLIC_KEY_PEM.encode()
84
+ )
85
+ _cache_timestamp = now
86
+ return _cached_public_key
87
+
88
+
41
89
  # MCP methods that require authentication
42
90
  # - tools/call: Executes tool logic, may cost money
43
91
  # - resources/read: Reads potentially sensitive data
@@ -155,10 +203,8 @@ async def verify_context_request(
155
203
  token = authorization_header.split(" ", 1)[1]
156
204
 
157
205
  try:
158
- # Load the public key
159
- public_key = serialization.load_pem_public_key(
160
- CONTEXT_PLATFORM_PUBLIC_KEY_PEM.encode()
161
- )
206
+ # Load the public key (tries JWKS endpoint first, falls back to hardcoded)
207
+ public_key = await _get_platform_public_key()
162
208
 
163
209
  # Build decode options - match TypeScript SDK behavior
164
210
  decode_options: dict[str, Any] = {
@@ -300,22 +346,74 @@ class ContextMiddleware:
300
346
  await self.app(scope, receive, send)
301
347
  return
302
348
 
303
- # We need to read the body to check the method
304
- # This is a simplified version - in production you might want to
305
- # use a more sophisticated approach to avoid reading the body twice
349
+ # Buffer the request body to inspect the MCP method
306
350
  body_parts: list[bytes] = []
351
+ body_complete = False
307
352
 
308
353
  async def receive_wrapper() -> dict[str, Any]:
354
+ nonlocal body_complete
355
+ if body_parts and body_complete:
356
+ # Replay the buffered body
357
+ body = b"".join(body_parts)
358
+ return {"type": "http.request", "body": body, "more_body": False}
359
+
309
360
  message = await receive()
310
361
  if message["type"] == "http.request":
311
362
  body_parts.append(message.get("body", b""))
363
+ if not message.get("more_body", False):
364
+ body_complete = True
312
365
  return message
313
366
 
314
- # For this middleware to work properly with body reading,
315
- # we need a stateful request object. In FastAPI/Starlette,
316
- # this is typically handled at the Request level.
317
- # Here we just pass through and let the endpoint handle auth.
318
- await self.app(scope, receive, send)
367
+ # Read the body to check the method
368
+ while not body_complete:
369
+ await receive_wrapper()
370
+
371
+ # Parse the body to get the MCP method
372
+ try:
373
+ import json
374
+ body_bytes = b"".join(body_parts)
375
+ body_json = json.loads(body_bytes)
376
+ method = body_json.get("method", "")
377
+ except Exception:
378
+ # Can't parse body - let the endpoint handle it
379
+ await self.app(scope, receive_wrapper, send)
380
+ return
381
+
382
+ # Allow discovery methods without authentication
383
+ if not method or not is_protected_mcp_method(method):
384
+ await self.app(scope, receive_wrapper, send)
385
+ return
386
+
387
+ # Protected method - require authentication
388
+ # Extract Authorization header from ASGI scope
389
+ headers = dict(scope.get("headers", []))
390
+ auth_header = headers.get(b"authorization", b"").decode("utf-8")
391
+
392
+ try:
393
+ payload = await verify_context_request(
394
+ authorization_header=auth_header if auth_header else None,
395
+ audience=self.audience,
396
+ )
397
+ # Attach payload to scope state for downstream use
398
+ if "state" not in scope:
399
+ scope["state"] = {}
400
+ scope["state"]["context"] = payload
401
+ await self.app(scope, receive_wrapper, send)
402
+ except ContextError as e:
403
+ # Return 401 JSON response
404
+ import json
405
+ response_body = json.dumps({"error": str(e)}).encode("utf-8")
406
+ await send({
407
+ "type": "http.response.start",
408
+ "status": e.status_code or 401,
409
+ "headers": [
410
+ [b"content-type", b"application/json"],
411
+ ],
412
+ })
413
+ await send({
414
+ "type": "http.response.body",
415
+ "body": response_body,
416
+ })
319
417
 
320
418
 
321
419
  def create_context_middleware(
@@ -380,6 +478,7 @@ def create_context_middleware(
380
478
  __all__ = [
381
479
  # Constants
382
480
  "CONTEXT_PLATFORM_PUBLIC_KEY_PEM",
481
+ "JWKS_URL",
383
482
  "PROTECTED_MCP_METHODS",
384
483
  "OPEN_MCP_METHODS",
385
484
  # Method classification
@@ -46,7 +46,8 @@ class Discovery:
46
46
  if limit is not None:
47
47
  params["limit"] = str(limit)
48
48
 
49
- query_string = "&".join(f"{k}={v}" for k, v in params.items())
49
+ from urllib.parse import urlencode
50
+ query_string = urlencode(params) if params else ""
50
51
  endpoint = f"/api/v1/tools/search{'?' + query_string if query_string else ''}"
51
52
 
52
53
  response = await self._client.fetch(endpoint)
@@ -82,7 +82,7 @@ class Tools:
82
82
  raise ContextError(
83
83
  message=error_response.error,
84
84
  code=error_response.code,
85
- status_code=400,
85
+ status_code=None, # Don't hardcode - this was a 200 OK with error body
86
86
  help_url=error_response.help_url,
87
87
  )
88
88
 
@@ -60,9 +60,13 @@ class Tool(BaseModel):
60
60
  price: Price per execution in USDC
61
61
  category: Tool category (e.g., "defi", "nft")
62
62
  is_verified: Whether the tool is verified by Context Protocol
63
+ kind: Tool type - currently always "mcp"
63
64
  mcp_tools: Available MCP tool methods
64
- created_at: Creation timestamp
65
- updated_at: Last update timestamp
65
+ total_queries: Total number of queries processed
66
+ success_rate: Success rate percentage (0-100)
67
+ uptime_percent: Uptime percentage (0-100)
68
+ total_staked: Total USDC staked by the developer
69
+ is_proven: Whether the tool has "Proven" status
66
70
  """
67
71
 
68
72
  id: str = Field(..., description="Unique identifier for the tool (UUID)")
@@ -75,20 +79,39 @@ class Tool(BaseModel):
75
79
  alias="isVerified",
76
80
  description="Whether the tool is verified by Context Protocol",
77
81
  )
82
+ kind: str | None = Field(
83
+ default=None,
84
+ description="Tool type - currently always 'mcp'",
85
+ )
78
86
  mcp_tools: list[McpTool] | None = Field(
79
87
  default=None,
80
88
  alias="mcpTools",
81
89
  description="Available MCP tool methods",
82
90
  )
83
- created_at: str | None = Field(
91
+ total_queries: int | None = Field(
92
+ default=None,
93
+ alias="totalQueries",
94
+ description="Total number of queries processed",
95
+ )
96
+ success_rate: str | None = Field(
97
+ default=None,
98
+ alias="successRate",
99
+ description="Success rate percentage",
100
+ )
101
+ uptime_percent: str | None = Field(
102
+ default=None,
103
+ alias="uptimePercent",
104
+ description="Uptime percentage",
105
+ )
106
+ total_staked: str | None = Field(
84
107
  default=None,
85
- alias="createdAt",
86
- description="Creation timestamp",
108
+ alias="totalStaked",
109
+ description="Total USDC staked by developer",
87
110
  )
88
- updated_at: str | None = Field(
111
+ is_proven: bool | None = Field(
89
112
  default=None,
90
- alias="updatedAt",
91
- description="Last update timestamp",
113
+ alias="isProven",
114
+ description="Whether tool has Proven status",
92
115
  )
93
116
 
94
117
  model_config = {"populate_by_name": True}
@@ -69,24 +69,37 @@ from ctxprotocol.context.hyperliquid import (
69
69
 
70
70
  CONTEXT_REQUIREMENTS_KEY = "x-context-requirements"
71
71
  """
72
- JSON Schema extension key for declaring context requirements.
72
+ DEPRECATED: Use _meta.contextRequirements instead.
73
73
 
74
- WHY THIS APPROACH?
75
- - MCP protocol only transmits: name, description, inputSchema, outputSchema
76
- - Custom fields like `requirements` get stripped by MCP SDK during transport
77
- - JSON Schema allows custom "x-" prefixed extension properties
78
- - inputSchema is preserved end-to-end through MCP transport
74
+ The primary mechanism for declaring context requirements is via the MCP _meta field
75
+ at the tool level, which is preserved by the MCP SDK through transport:
79
76
 
80
- Example:
81
77
  >>> tool = {
82
78
  ... "name": "analyze_my_positions",
79
+ ... "_meta": {"contextRequirements": ["hyperliquid"]},
83
80
  ... "inputSchema": {
84
81
  ... "type": "object",
85
- ... CONTEXT_REQUIREMENTS_KEY: ["hyperliquid"],
86
82
  ... "properties": {"portfolio": {"type": "object"}},
87
83
  ... "required": ["portfolio"]
88
84
  ... }
89
85
  ... }
86
+
87
+ This constant is kept for backwards compatibility but _meta.contextRequirements
88
+ is what the Context Platform reads. The x-context-requirements inputSchema extension
89
+ may be stripped by some MCP transports.
90
+ """
91
+
92
+ META_CONTEXT_REQUIREMENTS_KEY = "contextRequirements"
93
+ """
94
+ The key used inside _meta to declare context requirements.
95
+ This is the primary mechanism - the Context Platform reads _meta.contextRequirements.
96
+
97
+ Example:
98
+ >>> tool = {
99
+ ... "name": "analyze_positions",
100
+ ... "_meta": {META_CONTEXT_REQUIREMENTS_KEY: ["hyperliquid"]},
101
+ ... "inputSchema": {...}
102
+ ... }
90
103
  """
91
104
 
92
105
  # Context requirement types supported by the Context marketplace
@@ -158,6 +171,7 @@ class UserContext(BaseModel):
158
171
  __all__ = [
159
172
  # Constants
160
173
  "CONTEXT_REQUIREMENTS_KEY",
174
+ "META_CONTEXT_REQUIREMENTS_KEY",
161
175
  # Type aliases
162
176
  "ContextRequirementType",
163
177
  # Wallet types
@@ -34,7 +34,10 @@ from typing import Any, Literal, Optional, TypedDict, Union
34
34
 
35
35
 
36
36
  class HandshakeMeta(TypedDict, total=False):
37
- """UI metadata for handshake approval cards."""
37
+ """UI metadata for handshake approval cards.
38
+
39
+ Note: Keys use camelCase to match the wire format expected by the platform.
40
+ """
38
41
 
39
42
  description: str
40
43
  """Human-readable description of the action."""
@@ -45,15 +48,21 @@ class HandshakeMeta(TypedDict, total=False):
45
48
  action: str
46
49
  """Action verb (e.g., 'Place Order', 'Place Bid')."""
47
50
 
48
- token_symbol: str
51
+ tokenSymbol: str
49
52
  """Token symbol if relevant."""
50
53
 
51
- token_amount: str
54
+ tokenAmount: str
52
55
  """Human-readable token amount."""
53
56
 
54
- warning_level: Literal["info", "caution", "danger"]
57
+ warningLevel: Literal["info", "caution", "danger"]
55
58
  """UI warning level."""
56
59
 
60
+ title: str
61
+ """Title for the approval card."""
62
+
63
+ subtitle: str
64
+ """Subtitle for the approval card."""
65
+
57
66
 
58
67
  # =============================================================================
59
68
  # Web3: Signature Requests (EIP-712)
@@ -125,10 +134,10 @@ class SignatureRequest(TypedDict, total=False):
125
134
  class TransactionProposalMeta(HandshakeMeta, total=False):
126
135
  """Extended metadata for transaction proposals."""
127
136
 
128
- estimated_gas: str
137
+ estimatedGas: str
129
138
  """Estimated gas cost (informational - Context may sponsor)."""
130
139
 
131
- explorer_url: str
140
+ explorerUrl: str
132
141
  """Link to contract on block explorer."""
133
142
 
134
143
 
@@ -168,7 +177,7 @@ class TransactionProposal(TypedDict, total=False):
168
177
  class AuthRequiredMeta(TypedDict, total=False):
169
178
  """Metadata for OAuth requests."""
170
179
 
171
- display_name: str
180
+ displayName: str
172
181
  """Human-friendly service name."""
173
182
 
174
183
  scopes: list[str]
@@ -177,10 +186,10 @@ class AuthRequiredMeta(TypedDict, total=False):
177
186
  description: str
178
187
  """Description of what access is needed."""
179
188
 
180
- icon_url: str
189
+ iconUrl: str
181
190
  """Tool's icon URL."""
182
191
 
183
- expires_in: str
192
+ expiresIn: str
184
193
  """How long authorization lasts."""
185
194
 
186
195
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ctxprotocol"
7
- version = "0.5.7"
7
+ version = "0.6.0"
8
8
  description = "Official Python SDK for the Context Protocol - Discover and execute AI tools programmatically"
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes