ctxprotocol 0.5.6__py3-none-any.whl → 0.6.0__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.
ctxprotocol/__init__.py CHANGED
@@ -91,6 +91,31 @@ from ctxprotocol.auth import (
91
91
  CreateContextMiddlewareOptions,
92
92
  )
93
93
 
94
+ # Handshake types and helpers for tools that need user interaction
95
+ # (signatures, transactions, OAuth)
96
+ from ctxprotocol.handshake import (
97
+ # Types
98
+ HandshakeMeta,
99
+ EIP712Domain,
100
+ EIP712TypeField,
101
+ SignatureRequest,
102
+ TransactionProposalMeta,
103
+ TransactionProposal,
104
+ AuthRequiredMeta,
105
+ AuthRequired,
106
+ HandshakeAction,
107
+ # Type guards
108
+ is_handshake_action,
109
+ is_signature_request,
110
+ is_transaction_proposal,
111
+ is_auth_required,
112
+ # Helper functions
113
+ create_signature_request,
114
+ create_transaction_proposal,
115
+ create_auth_required,
116
+ wrap_handshake_response,
117
+ )
118
+
94
119
  __all__ = [
95
120
  # Version
96
121
  "__version__",
@@ -144,5 +169,25 @@ __all__ = [
144
169
  "ContextMiddleware",
145
170
  "VerifyRequestOptions",
146
171
  "CreateContextMiddlewareOptions",
172
+ # Handshake types
173
+ "HandshakeMeta",
174
+ "EIP712Domain",
175
+ "EIP712TypeField",
176
+ "SignatureRequest",
177
+ "TransactionProposalMeta",
178
+ "TransactionProposal",
179
+ "AuthRequiredMeta",
180
+ "AuthRequired",
181
+ "HandshakeAction",
182
+ # Handshake type guards
183
+ "is_handshake_action",
184
+ "is_signature_request",
185
+ "is_transaction_proposal",
186
+ "is_auth_required",
187
+ # Handshake helper functions
188
+ "create_signature_request",
189
+ "create_transaction_proposal",
190
+ "create_auth_required",
191
+ "wrap_handshake_response",
147
192
  ]
148
193
 
@@ -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
@@ -0,0 +1,427 @@
1
+ """
2
+ Handshake Module - Types and helpers for MCP tools that need user interaction.
3
+
4
+ Use these types when your tool needs to request user actions:
5
+ - Signatures (EIP-712 typed data for Hyperliquid, Polymarket, dYdX)
6
+ - Transactions (direct on-chain actions for Uniswap, NFT mints)
7
+ - OAuth (authentication flows for Discord, Twitter)
8
+
9
+ Example:
10
+ >>> from ctxprotocol.handshake import create_signature_request, wrap_handshake_response
11
+ >>>
12
+ >>> # In your MCP tool handler:
13
+ >>> def handle_place_order(args):
14
+ ... return wrap_handshake_response(
15
+ ... create_signature_request(
16
+ ... domain={"name": "Hyperliquid", "version": "1", "chainId": 42161},
17
+ ... types={"Order": [{"name": "asset", "type": "uint32"}, ...]},
18
+ ... primary_type="Order",
19
+ ... message={"asset": 4, "isBuy": True, ...},
20
+ ... meta={"description": "Place order", "protocol": "Hyperliquid"}
21
+ ... )
22
+ ... )
23
+
24
+ For more information, see: https://docs.ctxprotocol.com/guides/handshake-architecture
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Any, Literal, Optional, TypedDict, Union
30
+
31
+ # =============================================================================
32
+ # Shared Meta Types
33
+ # =============================================================================
34
+
35
+
36
+ class HandshakeMeta(TypedDict, total=False):
37
+ """UI metadata for handshake approval cards.
38
+
39
+ Note: Keys use camelCase to match the wire format expected by the platform.
40
+ """
41
+
42
+ description: str
43
+ """Human-readable description of the action."""
44
+
45
+ protocol: str
46
+ """Protocol name (e.g., 'Hyperliquid', 'Polymarket')."""
47
+
48
+ action: str
49
+ """Action verb (e.g., 'Place Order', 'Place Bid')."""
50
+
51
+ tokenSymbol: str
52
+ """Token symbol if relevant."""
53
+
54
+ tokenAmount: str
55
+ """Human-readable token amount."""
56
+
57
+ warningLevel: Literal["info", "caution", "danger"]
58
+ """UI warning level."""
59
+
60
+ title: str
61
+ """Title for the approval card."""
62
+
63
+ subtitle: str
64
+ """Subtitle for the approval card."""
65
+
66
+
67
+ # =============================================================================
68
+ # Web3: Signature Requests (EIP-712)
69
+ # =============================================================================
70
+
71
+
72
+ class EIP712Domain(TypedDict, total=False):
73
+ """EIP-712 domain separator."""
74
+
75
+ name: str
76
+ """Domain name (e.g., 'Hyperliquid', 'ClobAuthDomain')."""
77
+
78
+ version: str
79
+ """Domain version."""
80
+
81
+ chainId: int
82
+ """Chain ID (informational - signing is chain-agnostic)."""
83
+
84
+ verifyingContract: str
85
+ """Optional verifying contract address (0x...)."""
86
+
87
+
88
+ class EIP712TypeField(TypedDict):
89
+ """A single field in an EIP-712 type definition."""
90
+
91
+ name: str
92
+ type: str
93
+
94
+
95
+ class SignatureRequest(TypedDict, total=False):
96
+ """
97
+ Signature Request for EIP-712 typed data signing.
98
+
99
+ Use this for platforms with proxy wallets (Hyperliquid, Polymarket, dYdX).
100
+
101
+ Benefits:
102
+ - No gas required (user signs a message, not a transaction)
103
+ - No network switching needed (signing is chain-agnostic)
104
+ - Works with Privy embedded wallets on any chain
105
+ """
106
+
107
+ _action: Literal["signature_request"]
108
+ """Action type identifier (required)."""
109
+
110
+ domain: EIP712Domain
111
+ """EIP-712 domain separator (required)."""
112
+
113
+ types: dict[str, list[EIP712TypeField]]
114
+ """EIP-712 type definitions (required)."""
115
+
116
+ primaryType: str
117
+ """The primary type being signed (required)."""
118
+
119
+ message: dict[str, Any]
120
+ """The message data to sign (required)."""
121
+
122
+ meta: HandshakeMeta
123
+ """UI metadata for the approval card."""
124
+
125
+ callbackToolName: str
126
+ """Optional: Tool name to call with the signature result."""
127
+
128
+
129
+ # =============================================================================
130
+ # Web3: Transaction Proposals
131
+ # =============================================================================
132
+
133
+
134
+ class TransactionProposalMeta(HandshakeMeta, total=False):
135
+ """Extended metadata for transaction proposals."""
136
+
137
+ estimatedGas: str
138
+ """Estimated gas cost (informational - Context may sponsor)."""
139
+
140
+ explorerUrl: str
141
+ """Link to contract on block explorer."""
142
+
143
+
144
+ class TransactionProposal(TypedDict, total=False):
145
+ """
146
+ Transaction Proposal for direct on-chain actions.
147
+
148
+ Use this for protocols without proxy wallets (Uniswap, NFT mints, etc.).
149
+
150
+ Note: May require network switching and gas fees.
151
+ """
152
+
153
+ _action: Literal["transaction_proposal"]
154
+ """Action type identifier (required)."""
155
+
156
+ chainId: int
157
+ """EVM chain ID (e.g., 137 for Polygon, 8453 for Base) (required)."""
158
+
159
+ to: str
160
+ """Target contract address (0x...) (required)."""
161
+
162
+ data: str
163
+ """Encoded calldata (0x...) (required)."""
164
+
165
+ value: str
166
+ """Wei to send (as string, default '0')."""
167
+
168
+ meta: TransactionProposalMeta
169
+ """UI metadata for the approval card."""
170
+
171
+
172
+ # =============================================================================
173
+ # Web2: OAuth Requests
174
+ # =============================================================================
175
+
176
+
177
+ class AuthRequiredMeta(TypedDict, total=False):
178
+ """Metadata for OAuth requests."""
179
+
180
+ displayName: str
181
+ """Human-friendly service name."""
182
+
183
+ scopes: list[str]
184
+ """Permissions being requested."""
185
+
186
+ description: str
187
+ """Description of what access is needed."""
188
+
189
+ iconUrl: str
190
+ """Tool's icon URL."""
191
+
192
+ expiresIn: str
193
+ """How long authorization lasts."""
194
+
195
+
196
+ class AuthRequired(TypedDict, total=False):
197
+ """
198
+ Auth Required for OAuth flows.
199
+
200
+ Use this when your tool needs the user to authenticate with an external service.
201
+ """
202
+
203
+ _action: Literal["auth_required"]
204
+ """Action type identifier (required)."""
205
+
206
+ provider: str
207
+ """Service identifier (e.g., 'discord', 'slack') (required)."""
208
+
209
+ authUrl: str
210
+ """Your OAuth initiation endpoint (MUST be HTTPS) (required)."""
211
+
212
+ meta: AuthRequiredMeta
213
+ """UI metadata for the auth card."""
214
+
215
+
216
+ # =============================================================================
217
+ # Union Type
218
+ # =============================================================================
219
+
220
+ HandshakeAction = Union[SignatureRequest, TransactionProposal, AuthRequired]
221
+
222
+ # =============================================================================
223
+ # Type Guards
224
+ # =============================================================================
225
+
226
+
227
+ def is_handshake_action(value: Any) -> bool:
228
+ """Check if a value is a handshake action."""
229
+ if not isinstance(value, dict):
230
+ return False
231
+ action = value.get("_action")
232
+ return action in ("signature_request", "transaction_proposal", "auth_required")
233
+
234
+
235
+ def is_signature_request(value: Any) -> bool:
236
+ """Check if a value is a signature request."""
237
+ return is_handshake_action(value) and value.get("_action") == "signature_request"
238
+
239
+
240
+ def is_transaction_proposal(value: Any) -> bool:
241
+ """Check if a value is a transaction proposal."""
242
+ return is_handshake_action(value) and value.get("_action") == "transaction_proposal"
243
+
244
+
245
+ def is_auth_required(value: Any) -> bool:
246
+ """Check if a value is an auth required action."""
247
+ return is_handshake_action(value) and value.get("_action") == "auth_required"
248
+
249
+
250
+ # =============================================================================
251
+ # Helper Functions
252
+ # =============================================================================
253
+
254
+
255
+ def create_signature_request(
256
+ *,
257
+ domain: EIP712Domain,
258
+ types: dict[str, list[EIP712TypeField]],
259
+ primary_type: str,
260
+ message: dict[str, Any],
261
+ meta: HandshakeMeta | None = None,
262
+ callback_tool_name: str | None = None,
263
+ ) -> SignatureRequest:
264
+ """
265
+ Create a signature request response.
266
+
267
+ Use this for platforms with proxy wallets (Hyperliquid, Polymarket, dYdX).
268
+ Benefits: No gas required, no network switching needed.
269
+
270
+ Args:
271
+ domain: EIP-712 domain separator
272
+ types: EIP-712 type definitions
273
+ primary_type: The primary type being signed
274
+ message: The message data to sign
275
+ meta: Optional UI metadata for the approval card
276
+ callback_tool_name: Optional tool name to call with signature result
277
+
278
+ Returns:
279
+ A SignatureRequest dict ready to be wrapped in a handshake response
280
+ """
281
+ result: SignatureRequest = {
282
+ "_action": "signature_request",
283
+ "domain": domain,
284
+ "types": types,
285
+ "primaryType": primary_type,
286
+ "message": message,
287
+ }
288
+ if meta:
289
+ result["meta"] = meta
290
+ if callback_tool_name:
291
+ result["callbackToolName"] = callback_tool_name
292
+ return result
293
+
294
+
295
+ def create_transaction_proposal(
296
+ *,
297
+ chain_id: int,
298
+ to: str,
299
+ data: str,
300
+ value: str = "0",
301
+ meta: TransactionProposalMeta | None = None,
302
+ ) -> TransactionProposal:
303
+ """
304
+ Create a transaction proposal response.
305
+
306
+ Use this for protocols without proxy wallets (Uniswap, NFT mints, etc.).
307
+ Note: May require network switching and gas.
308
+
309
+ Args:
310
+ chain_id: EVM chain ID (e.g., 137 for Polygon, 8453 for Base)
311
+ to: Target contract address (0x...)
312
+ data: Encoded calldata (0x...)
313
+ value: Wei to send (as string, default '0')
314
+ meta: Optional UI metadata for the approval card
315
+
316
+ Returns:
317
+ A TransactionProposal dict ready to be wrapped in a handshake response
318
+ """
319
+ result: TransactionProposal = {
320
+ "_action": "transaction_proposal",
321
+ "chainId": chain_id,
322
+ "to": to,
323
+ "data": data,
324
+ "value": value,
325
+ }
326
+ if meta:
327
+ result["meta"] = meta
328
+ return result
329
+
330
+
331
+ def create_auth_required(
332
+ *,
333
+ provider: str,
334
+ auth_url: str,
335
+ meta: AuthRequiredMeta | None = None,
336
+ ) -> AuthRequired:
337
+ """
338
+ Create an auth required response.
339
+
340
+ Use this when your tool needs the user to authenticate via OAuth.
341
+
342
+ Args:
343
+ provider: Service identifier (e.g., 'discord', 'slack')
344
+ auth_url: Your OAuth initiation endpoint (MUST be HTTPS)
345
+ meta: Optional UI metadata for the auth card
346
+
347
+ Returns:
348
+ An AuthRequired dict ready to be wrapped in a handshake response
349
+ """
350
+ result: AuthRequired = {
351
+ "_action": "auth_required",
352
+ "provider": provider,
353
+ "authUrl": auth_url,
354
+ }
355
+ if meta:
356
+ result["meta"] = meta
357
+ return result
358
+
359
+
360
+ def wrap_handshake_response(action: HandshakeAction) -> dict[str, Any]:
361
+ """
362
+ Wrap a handshake action in the proper MCP response format.
363
+
364
+ MCP tools should return handshake actions in `_meta.handshakeAction` to prevent
365
+ the MCP SDK from stripping unknown fields.
366
+
367
+ Example:
368
+ >>> return wrap_handshake_response(create_signature_request(
369
+ ... domain={"name": "Hyperliquid", "version": "1", "chainId": 42161},
370
+ ... types={"Order": [...]},
371
+ ... primary_type="Order",
372
+ ... message=order_data,
373
+ ... meta={"description": "Place order", "protocol": "Hyperliquid"}
374
+ ... ))
375
+
376
+ Args:
377
+ action: The handshake action (SignatureRequest, TransactionProposal, or AuthRequired)
378
+
379
+ Returns:
380
+ A dict with the proper MCP response format including structuredContent._meta.handshakeAction
381
+ """
382
+ action_type = action.get("_action", "unknown").replace("_", " ")
383
+ description = action.get("meta", {}).get("description", f"{action_type} required")
384
+
385
+ return {
386
+ "content": [
387
+ {
388
+ "type": "text",
389
+ "text": f"Handshake required: {action_type}. Please approve in the Context app.",
390
+ }
391
+ ],
392
+ "structuredContent": {
393
+ "_meta": {
394
+ "handshakeAction": action,
395
+ },
396
+ "status": "handshake_required",
397
+ "message": description,
398
+ },
399
+ }
400
+
401
+
402
+ # =============================================================================
403
+ # Exports
404
+ # =============================================================================
405
+
406
+ __all__ = [
407
+ # Types
408
+ "HandshakeMeta",
409
+ "EIP712Domain",
410
+ "EIP712TypeField",
411
+ "SignatureRequest",
412
+ "TransactionProposalMeta",
413
+ "TransactionProposal",
414
+ "AuthRequiredMeta",
415
+ "AuthRequired",
416
+ "HandshakeAction",
417
+ # Type guards
418
+ "is_handshake_action",
419
+ "is_signature_request",
420
+ "is_transaction_proposal",
421
+ "is_auth_required",
422
+ # Helper functions
423
+ "create_signature_request",
424
+ "create_transaction_proposal",
425
+ "create_auth_required",
426
+ "wrap_handshake_response",
427
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxprotocol
3
- Version: 0.5.6
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
@@ -48,12 +48,26 @@ Context Protocol is **pip for AI capabilities**. Just as you install packages to
48
48
  [![Python versions](https://img.shields.io/pypi/pyversions/ctxprotocol.svg)](https://pypi.org/project/ctxprotocol/)
49
49
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
50
50
 
51
+ ---
52
+
53
+ ### 💰 $10,000 Developer Grant Program
54
+
55
+ We're funding the initial supply of MCP Tools for the Context Marketplace. **Become a Data Broker.**
56
+
57
+ - **🛠️ Build:** Create an MCP Server using this SDK (Solana data, Trading tools, Scrapers, etc.)
58
+ - **📦 List:** Publish it to the Context Registry
59
+ - **💵 Earn:** Get a **$250–$1,000 Grant** + earn USDC every time an agent queries your tool
60
+
61
+ 👉 [**View Open Bounties & Apply Here**](https://docs.ctxprotocol.com/grants)
62
+
63
+ ---
64
+
51
65
  ## Why use Context?
52
66
 
53
67
  - **🔌 One Interface, Everything:** Stop integrating APIs one by one. Use a single SDK to access any tool in the marketplace.
54
68
  - **🧠 Zero-Ops:** We're a gateway to the best MCP tools. Just send the JSON and get the result.
55
69
  - **⚡️ Agentic Discovery:** Your Agent can search the marketplace at runtime to find tools it didn't know it needed.
56
- - **💸 Micro-Billing:** Pay only for what you use (e.g., $0.001/query). No monthly subscriptions for tools you rarely use.
70
+ - **💸 Pay-Per-Response:** The $500/year subscription? Now $0.01/response. No monthly fees, just results.
57
71
 
58
72
  ## Who Is This SDK For?
59
73
 
@@ -282,6 +296,22 @@ The SDK implements a **selective authentication** model — discovery is open, e
282
296
 
283
297
  This matches standard API patterns (OpenAPI schemas are public, GraphQL introspection is open).
284
298
 
299
+ ## Execution Timeout & Product Design
300
+
301
+ ⚠️ **Important**: MCP tool execution has a **~60 second timeout** (enforced at the platform/client level, not by MCP itself). This is intentional—it encourages building pre-computed insight products rather than raw data access.
302
+
303
+ **Best practice**: Run heavy queries offline (via cron jobs), store results in your database, and serve instant results via MCP. This is how Bloomberg, Nansen, and Arkham work.
304
+
305
+ ```python
306
+ # ❌ BAD: Raw access (timeout-prone, no moat)
307
+ {"name": "run_sql", "description": "Run any SQL against blockchain data"}
308
+
309
+ # ✅ GOOD: Pre-computed product (instant, defensible)
310
+ {"name": "get_smart_money_wallets", "description": "Top 100 wallets that timed market tops"}
311
+ ```
312
+
313
+ See the [full documentation](https://docs.ctxprotocol.com/guides/build-tools#execution-limits--product-design) for detailed guidance.
314
+
285
315
  ## Context Injection (Personalized Tools)
286
316
 
287
317
  For tools that analyze user data, Context automatically injects user context:
@@ -0,0 +1,17 @@
1
+ ctxprotocol/__init__.py,sha256=m7s0VtVaMwm-cBBXpvBN30M3RfgDgMTIwcpY_vqVHFg,4755
2
+ ctxprotocol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ ctxprotocol/auth/__init__.py,sha256=zXo2n9ZvVN6H0Oa5oE_4J6j6KdksymC-SFAzlLUm7JI,16645
4
+ ctxprotocol/client/__init__.py,sha256=dgtQ9_pzVthsNJazibWHcFkdTjZQlT_gn7SuPCsBOHo,940
5
+ ctxprotocol/client/client.py,sha256=eEiSTmBY6VHP04LbBEGF-D_9D5oZvDgaVF1ysMVS_zc,4771
6
+ ctxprotocol/client/types.py,sha256=cKJ0vdkaMckIgh7QR8gMhTDzUMAu4agDu9xyY9-WCX8,8961
7
+ ctxprotocol/client/resources/__init__.py,sha256=ZJkrArhJtYMALMIu46m2JU6XimBcIUvd0LvZPi7Cpz0,238
8
+ ctxprotocol/client/resources/discovery.py,sha256=BH-rN53GhgG_4Ecl2lqr-tgyxHShrqpawFV8ndJThAU,2107
9
+ ctxprotocol/client/resources/tools.py,sha256=gV5HbssUveJjouJ4EaitL-Ouph3IK9f0cbLz4fnltZw,3425
10
+ ctxprotocol/context/__init__.py,sha256=3uP-_7iULzwv0bV-CLznjRWAjsA38oCIhJzpWsxmvBM,6297
11
+ ctxprotocol/context/hyperliquid.py,sha256=M59Ku48yCdfpS9_b5l8SyEFukZwbNb8P1xAh2P0Yh-0,7603
12
+ ctxprotocol/context/polymarket.py,sha256=GcBay3VRKf4MQj49VLmhOPrU172_aNa4i2TPS9-sZuQ,3484
13
+ ctxprotocol/context/wallet.py,sha256=g6pXMY_olLn3-4ULQQKmtpDcZ_u9kgCwIdc0JFEkckE,1742
14
+ ctxprotocol/handshake/__init__.py,sha256=oz-dQO5hwbzCxaPFUeOMQDolfy14K55GfygTtKmu-IY,12498
15
+ ctxprotocol-0.6.0.dist-info/METADATA,sha256=11JEebdyq30IQHkbCo-dQEUgRQXdJrnPIVVpVf4CR2I,11706
16
+ ctxprotocol-0.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
17
+ ctxprotocol-0.6.0.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- ctxprotocol/__init__.py,sha256=3nI2BrD1dmzezm1vi8pcKgrErrkX5K_Izyb3LbnXdSo,3613
2
- ctxprotocol/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- ctxprotocol/auth/__init__.py,sha256=J1AWTpN-KKCYfFRMtIW8z8jvBvJHE4cB7Z6t2PjFsVA,13202
4
- ctxprotocol/client/__init__.py,sha256=dgtQ9_pzVthsNJazibWHcFkdTjZQlT_gn7SuPCsBOHo,940
5
- ctxprotocol/client/client.py,sha256=eEiSTmBY6VHP04LbBEGF-D_9D5oZvDgaVF1ysMVS_zc,4771
6
- ctxprotocol/client/types.py,sha256=3FOL5SuzzhiPAGTl-sWasDSxkEKnNjgyoaXc8wjlkOI,8143
7
- ctxprotocol/client/resources/__init__.py,sha256=ZJkrArhJtYMALMIu46m2JU6XimBcIUvd0LvZPi7Cpz0,238
8
- ctxprotocol/client/resources/discovery.py,sha256=xnykG55DfO_TjD6Z5taLc7aGklg98X8boPiBYsx3NFE,2076
9
- ctxprotocol/client/resources/tools.py,sha256=ZkBLz-AKZI_6MUPIQMCj80PDo7gd6YgxXPYHqRJaEb4,3370
10
- ctxprotocol/context/__init__.py,sha256=v_auetqp3w0eScRLhWGbFHXGRfgjcNzTEeeMKOAxigA,5819
11
- ctxprotocol/context/hyperliquid.py,sha256=M59Ku48yCdfpS9_b5l8SyEFukZwbNb8P1xAh2P0Yh-0,7603
12
- ctxprotocol/context/polymarket.py,sha256=GcBay3VRKf4MQj49VLmhOPrU172_aNa4i2TPS9-sZuQ,3484
13
- ctxprotocol/context/wallet.py,sha256=g6pXMY_olLn3-4ULQQKmtpDcZ_u9kgCwIdc0JFEkckE,1742
14
- ctxprotocol-0.5.6.dist-info/METADATA,sha256=hbhW8_vWmyaKWVly5hbLoU8ToV83swFWHHVxR0klTnw,10364
15
- ctxprotocol-0.5.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- ctxprotocol-0.5.6.dist-info/RECORD,,