okb 1.1.0__tar.gz → 1.1.1__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 (48) hide show
  1. {okb-1.1.0 → okb-1.1.1}/PKG-INFO +9 -3
  2. {okb-1.1.0 → okb-1.1.1}/README.md +8 -2
  3. {okb-1.1.0 → okb-1.1.1}/okb/http_server.py +130 -88
  4. {okb-1.1.0 → okb-1.1.1}/pyproject.toml +1 -1
  5. {okb-1.1.0 → okb-1.1.1}/okb/__init__.py +0 -0
  6. {okb-1.1.0 → okb-1.1.1}/okb/cli.py +0 -0
  7. {okb-1.1.0 → okb-1.1.1}/okb/config.py +0 -0
  8. {okb-1.1.0 → okb-1.1.1}/okb/data/init.sql +0 -0
  9. {okb-1.1.0 → okb-1.1.1}/okb/ingest.py +0 -0
  10. {okb-1.1.0 → okb-1.1.1}/okb/llm/__init__.py +0 -0
  11. {okb-1.1.0 → okb-1.1.1}/okb/llm/analyze.py +0 -0
  12. {okb-1.1.0 → okb-1.1.1}/okb/llm/base.py +0 -0
  13. {okb-1.1.0 → okb-1.1.1}/okb/llm/cache.py +0 -0
  14. {okb-1.1.0 → okb-1.1.1}/okb/llm/consolidate.py +0 -0
  15. {okb-1.1.0 → okb-1.1.1}/okb/llm/enrich.py +0 -0
  16. {okb-1.1.0 → okb-1.1.1}/okb/llm/extractors/__init__.py +0 -0
  17. {okb-1.1.0 → okb-1.1.1}/okb/llm/extractors/base.py +0 -0
  18. {okb-1.1.0 → okb-1.1.1}/okb/llm/extractors/cross_doc.py +0 -0
  19. {okb-1.1.0 → okb-1.1.1}/okb/llm/extractors/dedup.py +0 -0
  20. {okb-1.1.0 → okb-1.1.1}/okb/llm/extractors/entity.py +0 -0
  21. {okb-1.1.0 → okb-1.1.1}/okb/llm/extractors/todo.py +0 -0
  22. {okb-1.1.0 → okb-1.1.1}/okb/llm/filter.py +0 -0
  23. {okb-1.1.0 → okb-1.1.1}/okb/llm/providers.py +0 -0
  24. {okb-1.1.0 → okb-1.1.1}/okb/local_embedder.py +0 -0
  25. {okb-1.1.0 → okb-1.1.1}/okb/mcp_server.py +0 -0
  26. {okb-1.1.0 → okb-1.1.1}/okb/migrate.py +0 -0
  27. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0001.initial-schema.sql +0 -0
  28. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0002.sync-state.sql +0 -0
  29. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0003.structured-fields.sql +0 -0
  30. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0004.tokens.sql +0 -0
  31. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0005.database-metadata.sql +0 -0
  32. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0006.llm-cache.sql +0 -0
  33. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0008.enrichment.sql +0 -0
  34. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0009.entity-consolidation.sql +0 -0
  35. {okb-1.1.0 → okb-1.1.1}/okb/migrations/0010.token-id.sql +0 -0
  36. {okb-1.1.0 → okb-1.1.1}/okb/modal_embedder.py +0 -0
  37. {okb-1.1.0 → okb-1.1.1}/okb/modal_llm.py +0 -0
  38. {okb-1.1.0 → okb-1.1.1}/okb/plugins/__init__.py +0 -0
  39. {okb-1.1.0 → okb-1.1.1}/okb/plugins/base.py +0 -0
  40. {okb-1.1.0 → okb-1.1.1}/okb/plugins/registry.py +0 -0
  41. {okb-1.1.0 → okb-1.1.1}/okb/plugins/sources/__init__.py +0 -0
  42. {okb-1.1.0 → okb-1.1.1}/okb/plugins/sources/dropbox_paper.py +0 -0
  43. {okb-1.1.0 → okb-1.1.1}/okb/plugins/sources/github.py +0 -0
  44. {okb-1.1.0 → okb-1.1.1}/okb/plugins/sources/todoist.py +0 -0
  45. {okb-1.1.0 → okb-1.1.1}/okb/rescan.py +0 -0
  46. {okb-1.1.0 → okb-1.1.1}/okb/scripts/__init__.py +0 -0
  47. {okb-1.1.0 → okb-1.1.1}/okb/scripts/watch.py +0 -0
  48. {okb-1.1.0 → okb-1.1.1}/okb/tokens.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: okb
3
- Version: 1.1.0
3
+ Version: 1.1.1
4
4
  Summary: Personal knowledge base with semantic search for LLMs
5
5
  Requires-Python: >=3.11
6
6
  Classifier: Programming Language :: Python :: 3
@@ -293,14 +293,20 @@ okb token create --db default -d "Claude Code"
293
293
  okb serve --http --host 0.0.0.0 --port 8080
294
294
  ```
295
295
 
296
- Then configure Claude Code to connect via SSE:
296
+ The server uses Streamable HTTP transport (RFC 9728 compliant):
297
+ - `POST /mcp` - Send JSON-RPC messages, receive SSE response
298
+ - `GET /mcp` - Establish SSE connection for server notifications
299
+ - `DELETE /mcp` - Terminate session
300
+ - `/sse` is an alias for `/mcp` for backward compatibility
301
+
302
+ Configure your MCP client to connect:
297
303
 
298
304
  ```json
299
305
  {
300
306
  "mcpServers": {
301
307
  "knowledge-base": {
302
308
  "type": "sse",
303
- "url": "http://localhost:8080/sse",
309
+ "url": "http://localhost:8080/mcp",
304
310
  "headers": {
305
311
  "Authorization": "Bearer okb_default_rw_a1b2c3d4e5f6g7h8"
306
312
  }
@@ -244,14 +244,20 @@ okb token create --db default -d "Claude Code"
244
244
  okb serve --http --host 0.0.0.0 --port 8080
245
245
  ```
246
246
 
247
- Then configure Claude Code to connect via SSE:
247
+ The server uses Streamable HTTP transport (RFC 9728 compliant):
248
+ - `POST /mcp` - Send JSON-RPC messages, receive SSE response
249
+ - `GET /mcp` - Establish SSE connection for server notifications
250
+ - `DELETE /mcp` - Terminate session
251
+ - `/sse` is an alias for `/mcp` for backward compatibility
252
+
253
+ Configure your MCP client to connect:
248
254
 
249
255
  ```json
250
256
  {
251
257
  "mcpServers": {
252
258
  "knowledge-base": {
253
259
  "type": "sse",
254
- "url": "http://localhost:8080/sse",
260
+ "url": "http://localhost:8080/mcp",
255
261
  "headers": {
256
262
  "Authorization": "Bearer okb_default_rw_a1b2c3d4e5f6g7h8"
257
263
  }
@@ -1,9 +1,15 @@
1
1
  """HTTP transport server for MCP with token authentication.
2
2
 
3
- This module provides an HTTP server that serves the LKB MCP server with
4
- token-based authentication. Tokens can be passed via Authorization header
5
- or query parameter. A single HTTP server can serve multiple databases,
6
- with the token determining which database to use.
3
+ This module provides an HTTP server that serves the OKB MCP server with
4
+ token-based authentication using Streamable HTTP transport. Tokens can be
5
+ passed via Authorization header or query parameter. A single HTTP server
6
+ can serve multiple databases, with the token determining which database to use.
7
+
8
+ Transport: Streamable HTTP (RFC 9728 compliant)
9
+ - POST /mcp → send JSON-RPC messages, get SSE response
10
+ - GET /mcp → optional standalone SSE for server notifications
11
+ - DELETE /mcp → terminate session
12
+ - Session ID in Mcp-Session-Id header
7
13
  """
8
14
 
9
15
  from __future__ import annotations
@@ -12,12 +18,11 @@ import sys
12
18
  from typing import Any
13
19
 
14
20
  from mcp.server import Server
15
- from mcp.server.sse import SseServerTransport
21
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
16
22
  from mcp.types import CallToolResult, TextContent, Tool
17
- from starlette.applications import Starlette
23
+ from starlette.middleware.cors import CORSMiddleware
18
24
  from starlette.requests import Request
19
- from starlette.responses import JSONResponse, Response
20
- from starlette.routing import Mount, Route
25
+ from starlette.responses import JSONResponse
21
26
 
22
27
  from .config import config
23
28
  from .local_embedder import warmup
@@ -81,14 +86,14 @@ def extract_token(request: Request) -> str | None:
81
86
 
82
87
 
83
88
  class HTTPMCPServer:
84
- """HTTP server for MCP with token authentication."""
89
+ """HTTP server for MCP with token authentication using Streamable HTTP transport."""
85
90
 
86
91
  def __init__(self):
87
92
  self.knowledge_bases: dict[str, KnowledgeBase] = {}
88
93
  self.server = Server("knowledge-base")
89
- # Single shared transport instance for all connections
90
- self.transport = SseServerTransport("/messages/")
91
- # Map session_id (hex string) -> token_info
94
+ # Session manager handles all transport complexity
95
+ self.session_manager = StreamableHTTPSessionManager(app=self.server)
96
+ # Map mcp-session-id -> token_info
92
97
  self.session_tokens: dict[str, TokenInfo] = {}
93
98
  self._setup_handlers()
94
99
 
@@ -563,95 +568,131 @@ class HTTPMCPServer:
563
568
  except Exception as e:
564
569
  return CallToolResult(content=[TextContent(type="text", text=f"Error: {e!s}")])
565
570
 
566
- def create_app(self) -> Starlette:
571
+ def create_app(self):
567
572
  """Create the Starlette application."""
568
573
  verifier = OKBTokenVerifier(self._get_db_url)
574
+ session_header_name = "mcp-session-id"
569
575
 
570
- async def handle_sse(request: Request) -> Response:
571
- """Handle SSE connections for MCP."""
572
- # Verify token
573
- token = extract_token(request)
574
- if not token:
575
- return JSONResponse(
576
- {"error": "Missing token. Use Authorization header or ?token= parameter"},
577
- status_code=401,
578
- )
576
+ def create_mcp_handler():
577
+ """Create an ASGI handler for MCP with auth."""
579
578
 
580
- token_info = verifier.verify(token)
581
- if not token_info:
582
- return JSONResponse(
583
- {"error": "Invalid or expired token"},
584
- status_code=401,
585
- )
579
+ async def handle_mcp(scope, receive, send):
580
+ """Handle all MCP requests (GET, POST, DELETE) with auth."""
581
+ request = Request(scope, receive)
586
582
 
587
- # Track existing sessions before connecting
588
- existing_sessions = set(self.transport._read_stream_writers.keys())
589
-
590
- async with self.transport.connect_sse(
591
- request.scope, request.receive, request._send
592
- ) as (read_stream, write_stream):
593
- # Find the new session ID by comparing before/after
594
- current_sessions = set(self.transport._read_stream_writers.keys())
595
- new_sessions = current_sessions - existing_sessions
596
- if not new_sessions:
597
- return JSONResponse(
598
- {"error": "Failed to establish session"},
599
- status_code=500,
583
+ # Extract and verify token
584
+ token = extract_token(request)
585
+ if not token:
586
+ response = JSONResponse(
587
+ {"error": "Missing token. Use Authorization header or ?token= param"},
588
+ status_code=401,
600
589
  )
601
- session_id = new_sessions.pop()
602
- session_id_hex = session_id.hex
603
-
604
- # Store token mapping for this session
605
- self.session_tokens[session_id_hex] = token_info
606
- self.server._current_token_info = token_info
607
-
608
- try:
609
- await self.server.run(
610
- read_stream, write_stream, self.server.create_initialization_options()
590
+ await response(scope, receive, send)
591
+ return
592
+
593
+ token_info = verifier.verify(token)
594
+ if not token_info:
595
+ response = JSONResponse(
596
+ {"error": "Invalid or expired token"},
597
+ status_code=401,
611
598
  )
612
- finally:
613
- # Clean up session on disconnect
614
- self.session_tokens.pop(session_id_hex, None)
615
-
616
- return Response()
617
-
618
- async def handle_messages(scope, receive, send):
619
- """Handle POST messages for MCP (raw ASGI handler)."""
620
- request = Request(scope, receive)
621
-
622
- # Look up session from query params
623
- session_id = request.query_params.get("session_id")
624
- if not session_id:
625
- response = JSONResponse({"error": "Missing session_id"}, status_code=400)
626
- await response(scope, receive, send)
627
- return
628
-
629
- token_info = self.session_tokens.get(session_id)
630
- if not token_info:
631
- response = JSONResponse({"error": "Invalid or expired session"}, status_code=401)
632
- await response(scope, receive, send)
633
- return
599
+ await response(scope, receive, send)
600
+ return
601
+
602
+ # Check if this is an existing session
603
+ session_id = request.headers.get(session_header_name)
604
+ if session_id:
605
+ # Verify token matches existing session (compare by hash and db, not object)
606
+ existing_token = self.session_tokens.get(session_id)
607
+ if existing_token:
608
+ # Token must match the one used to create the session
609
+ if (
610
+ existing_token.token_hash != token_info.token_hash
611
+ or existing_token.database != token_info.database
612
+ ):
613
+ response = JSONResponse(
614
+ {"error": "Token mismatch for session"},
615
+ status_code=401,
616
+ )
617
+ await response(scope, receive, send)
618
+ return
634
619
 
635
- # Set current token info for tool calls
636
- self.server._current_token_info = token_info
620
+ # Set current token info for tool calls
621
+ self.server._current_token_info = token_info
637
622
 
638
- await self.transport.handle_post_message(scope, receive, send)
623
+ # Wrap send to capture the session ID from response headers
624
+ captured_session_id = None
639
625
 
640
- async def health(request: Request) -> JSONResponse:
641
- """Health check endpoint."""
642
- return JSONResponse({"status": "ok"})
626
+ async def send_wrapper(message):
627
+ nonlocal captured_session_id
628
+ if message["type"] == "http.response.start":
629
+ headers = message.get("headers", [])
630
+ for name, value in headers:
631
+ header_name = (
632
+ name.lower() if isinstance(name, bytes) else name.lower().encode()
633
+ )
634
+ if header_name == session_header_name.encode():
635
+ captured_session_id = (
636
+ value.decode() if isinstance(value, bytes) else value
637
+ )
638
+ # Store immediately since SSE keeps connection open
639
+ if captured_session_id not in self.session_tokens:
640
+ self.session_tokens[captured_session_id] = token_info
641
+ break
642
+ await send(message)
643
+
644
+ # Delegate to session manager
645
+ await self.session_manager.handle_request(scope, receive, send_wrapper)
646
+
647
+ return handle_mcp
648
+
649
+ # Create the MCP handler ASGI app
650
+ mcp_handler = create_mcp_handler()
651
+
652
+ # Custom ASGI app that routes /mcp and /sse to MCP handler
653
+ async def router(scope, receive, send):
654
+ if scope["type"] == "http":
655
+ path = scope["path"].rstrip("/") # Handle trailing slash
656
+ if path in ("/mcp", "/sse"):
657
+ await mcp_handler(scope, receive, send)
658
+ return
659
+ elif path == "/health" or scope["path"] == "/health":
660
+ response = JSONResponse({"status": "ok"})
661
+ await response(scope, receive, send)
662
+ return
663
+ # 404 for unknown paths
664
+ response = JSONResponse({"error": "Not found"}, status_code=404)
665
+ await response(scope, receive, send)
666
+
667
+ # Wrap with lifespan handling
668
+ async def app_with_lifespan(scope, receive, send):
669
+ if scope["type"] == "lifespan":
670
+ async with self.session_manager.run():
671
+ # Handle lifespan protocol
672
+ while True:
673
+ message = await receive()
674
+ if message["type"] == "lifespan.startup":
675
+ await send({"type": "lifespan.startup.complete"})
676
+ elif message["type"] == "lifespan.shutdown":
677
+ await send({"type": "lifespan.shutdown.complete"})
678
+ return
679
+ else:
680
+ await router(scope, receive, send)
643
681
 
644
- routes = [
645
- Route("/health", health, methods=["GET"]),
646
- Route("/sse", handle_sse, methods=["GET"]),
647
- Mount("/messages", app=handle_messages),
648
- ]
682
+ # Add CORS for browser clients - wrap the raw ASGI app
683
+ app = CORSMiddleware(
684
+ app_with_lifespan,
685
+ allow_origins=["*"],
686
+ allow_methods=["GET", "POST", "DELETE"],
687
+ allow_headers=["Authorization", "Content-Type", session_header_name],
688
+ expose_headers=[session_header_name],
689
+ )
649
690
 
650
- return Starlette(routes=routes)
691
+ return app
651
692
 
652
693
 
653
694
  def run_http_server(host: str = "127.0.0.1", port: int = 8080):
654
- """Run the HTTP MCP server."""
695
+ """Run the HTTP MCP server with Streamable HTTP transport."""
655
696
  import uvicorn
656
697
 
657
698
  print("Warming up embedding model...", file=sys.stderr)
@@ -662,8 +703,9 @@ def run_http_server(host: str = "127.0.0.1", port: int = 8080):
662
703
  app = http_server.create_app()
663
704
 
664
705
  print(f"Starting HTTP MCP server on http://{host}:{port}", file=sys.stderr)
665
- print(" SSE endpoint: /sse", file=sys.stderr)
666
- print(" Messages endpoint: /messages/", file=sys.stderr)
706
+ print(" MCP endpoint: /mcp (GET, POST, DELETE)", file=sys.stderr)
707
+ print(" MCP endpoint: /sse (alias for /mcp)", file=sys.stderr)
667
708
  print(" Health endpoint: /health", file=sys.stderr)
709
+ print(" Transport: Streamable HTTP", file=sys.stderr)
668
710
 
669
711
  uvicorn.run(app, host=host, port=port, log_level="info")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "okb"
3
- version = "1.1.0"
3
+ version = "1.1.1"
4
4
  description = "Personal knowledge base with semantic search for LLMs"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes