contextportal 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.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: contextportal
3
+ Version: 0.1.0
4
+ Summary: The authenticated fetch layer for AI agents.
5
+ Author: NavadeepDj
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/NavadeepDj/ContextPortal
8
+ Project-URL: Repository, https://github.com/NavadeepDj/ContextPortal
9
+ Project-URL: Issues, https://github.com/NavadeepDj/ContextPortal/issues
10
+ Keywords: mcp,ai-agents,retrieval,browser-automation,context-proxy,security
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: fastapi>=0.115.0
21
+ Requires-Dist: uvicorn[standard]>=0.30.0
22
+ Requires-Dist: redis[hiredis]>=5.0.0
23
+ Requires-Dist: pydantic-settings>=2.4.0
24
+ Requires-Dist: httpx>=0.27.0
25
+ Requires-Dist: playwright>=1.62.0
26
+ Requires-Dist: readability-lxml>=0.8.4.1
27
+ Requires-Dist: markdownify>=1.2.3
28
+ Requires-Dist: mcp[cli]>=2.1.1
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
31
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
33
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
34
+ Requires-Dist: mypy>=1.11.0; extra == "dev"
35
+ Requires-Dist: twine>=5.0.0; extra == "dev"
36
+
37
+ # πŸ”‘ ContextPortal
38
+
39
+ > **Give your AI agent access to authenticated web pages β€” without giving it your credentials.**
40
+
41
+ Your AI agent can fetch any public webpage. But the moment it hits a login wall β€” Jira, Confluence, internal wikis, enterprise dashboards β€” it's stuck.
42
+
43
+ ContextPortal sits between your AI agent and the web. When the agent needs a protected page, ContextPortal uses *your* existing browser session to grab it, strips out all the noise, and hands back clean Markdown. The agent gets context. It never gets your cookies.
44
+
45
+ > πŸͺ *Agent: "Can I have your cookies?"*
46
+ > 🚫 *ContextPortal: "No. Here's the page."*
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ uv tool install contextportal
52
+ ```
53
+
54
+ ## Setup
55
+
56
+ ```bash
57
+ # Log into your private sites (once)
58
+ contextportal login
59
+
60
+ # Test it
61
+ contextportal fetch https://your-protected-site.com/docs
62
+ ```
63
+
64
+ ## Connect to your AI Agent
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "context-portal": {
70
+ "command": "contextportal",
71
+ "args": ["mcp"]
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ Works with **Cursor**, **Claude Desktop**, **Antigravity**, **VS Code**, and any MCP-compatible client.
78
+
79
+ ## Learn More
80
+
81
+ - πŸ“– [Full Documentation](https://github.com/NavadeepDj/ContextPortal)
82
+ - πŸ” [Security Model](https://github.com/NavadeepDj/ContextPortal/blob/main/docs/security-and-automation-philosophy.md)
83
+ - πŸ—ΊοΈ [Product Roadmap](https://github.com/NavadeepDj/ContextPortal/blob/main/PRODUCT_ROADMAP.md)
@@ -0,0 +1,47 @@
1
+ # πŸ”‘ ContextPortal
2
+
3
+ > **Give your AI agent access to authenticated web pages β€” without giving it your credentials.**
4
+
5
+ Your AI agent can fetch any public webpage. But the moment it hits a login wall β€” Jira, Confluence, internal wikis, enterprise dashboards β€” it's stuck.
6
+
7
+ ContextPortal sits between your AI agent and the web. When the agent needs a protected page, ContextPortal uses *your* existing browser session to grab it, strips out all the noise, and hands back clean Markdown. The agent gets context. It never gets your cookies.
8
+
9
+ > πŸͺ *Agent: "Can I have your cookies?"*
10
+ > 🚫 *ContextPortal: "No. Here's the page."*
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ uv tool install contextportal
16
+ ```
17
+
18
+ ## Setup
19
+
20
+ ```bash
21
+ # Log into your private sites (once)
22
+ contextportal login
23
+
24
+ # Test it
25
+ contextportal fetch https://your-protected-site.com/docs
26
+ ```
27
+
28
+ ## Connect to your AI Agent
29
+
30
+ ```json
31
+ {
32
+ "mcpServers": {
33
+ "context-portal": {
34
+ "command": "contextportal",
35
+ "args": ["mcp"]
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ Works with **Cursor**, **Claude Desktop**, **Antigravity**, **VS Code**, and any MCP-compatible client.
42
+
43
+ ## Learn More
44
+
45
+ - πŸ“– [Full Documentation](https://github.com/NavadeepDj/ContextPortal)
46
+ - πŸ” [Security Model](https://github.com/NavadeepDj/ContextPortal/blob/main/docs/security-and-automation-philosophy.md)
47
+ - πŸ—ΊοΈ [Product Roadmap](https://github.com/NavadeepDj/ContextPortal/blob/main/PRODUCT_ROADMAP.md)
@@ -0,0 +1 @@
1
+ """ContextPortal backend application package."""
@@ -0,0 +1,121 @@
1
+ import argparse
2
+ import sys
3
+ import asyncio
4
+ from typing import Optional
5
+
6
+ def run_mcp() -> None:
7
+ """Run the ContextPortal MCP server."""
8
+ from app.mcp.server import main as mcp_main
9
+ mcp_main()
10
+
11
+ def run_login(start_url: Optional[str] = None) -> None:
12
+ """Launch persistent browser for manual login and session seeding."""
13
+ from playwright.sync_api import sync_playwright
14
+ import time
15
+ from pathlib import Path
16
+
17
+ # Use global profile directory so sessions persist no matter where the CLI is run
18
+ profile_dir = Path.home() / ".contextportal" / "playwright_profile"
19
+ print(f"Using persistent profile at: {profile_dir}")
20
+ print("A browser window will now open.")
21
+ print("1. Navigate to the websites you want your AI agent to access.")
22
+ print("2. Log in manually (solve captchas, use SSO, etc.).")
23
+ print("3. Close the browser window when you are finished.")
24
+ print("\nLaunching browser...\n")
25
+
26
+ with sync_playwright() as p:
27
+ # We launch headful (headless=False) so the user can interact
28
+ browser_context = p.chromium.launch_persistent_context(
29
+ user_data_dir=str(profile_dir),
30
+ headless=False,
31
+ channel="chrome", # ADR-003: Transparent automation
32
+ args=["--disable-blink-features=AutomationControlled"]
33
+ )
34
+
35
+ page = browser_context.pages[0] if browser_context.pages else browser_context.new_page()
36
+
37
+ if start_url:
38
+ print(f"Navigating to {start_url}...")
39
+ page.goto(start_url)
40
+ else:
41
+ page.goto("about:blank")
42
+
43
+ print("\nWaiting for you to close the browser window...")
44
+ print("Your session will be saved automatically.")
45
+
46
+ # Keep process alive until user closes the context/browser
47
+ try:
48
+ while browser_context.pages:
49
+ time.sleep(1)
50
+ except Exception:
51
+ pass
52
+
53
+ print("\nBrowser closed. Session saved successfully!")
54
+ print("You can now ask your AI agent to fetch authenticated resources.")
55
+
56
+ def run_fetch(url: str) -> None:
57
+ """Fetch a URL directly from the CLI (useful for testing without an agent)."""
58
+ from app.core.retriever import get_context
59
+
60
+ print(f"Fetching context for: {url}\n")
61
+
62
+ async def _do_fetch():
63
+ result = await get_context(url)
64
+ if not result:
65
+ print("Error: Could not retrieve content.")
66
+ sys.exit(1)
67
+
68
+ print("="*80)
69
+ print(f"Title: {result.title}")
70
+ print(f"Retrieval Method: {result.retrieval_method} (Authenticated: {result.authenticated})")
71
+ print("="*80)
72
+ print(result.content)
73
+
74
+ asyncio.run(_do_fetch())
75
+
76
+ def main() -> None:
77
+ parser = argparse.ArgumentParser(
78
+ description="ContextPortal β€” The authenticated fetch layer for AI agents."
79
+ )
80
+ subparsers = parser.add_subparsers(dest="command", required=True)
81
+
82
+ # MCP Server Command
83
+ subparsers.add_parser(
84
+ "mcp",
85
+ help="Start the Model Context Protocol (MCP) server over STDIO."
86
+ )
87
+
88
+ # Login Command
89
+ login_parser = subparsers.add_parser(
90
+ "login",
91
+ help="Open a browser to manually authenticate and save your session."
92
+ )
93
+ login_parser.add_argument(
94
+ "--url",
95
+ type=str,
96
+ help="Optional starting URL to open for login (e.g., https://github.com/login)."
97
+ )
98
+
99
+ # Fetch Command
100
+ fetch_parser = subparsers.add_parser(
101
+ "fetch",
102
+ help="Test retrieval of a URL directly from the CLI."
103
+ )
104
+ fetch_parser.add_argument(
105
+ "url",
106
+ type=str,
107
+ help="The URL to fetch."
108
+ )
109
+
110
+ args = parser.parse_args()
111
+
112
+ if args.command == "mcp":
113
+ run_mcp()
114
+ elif args.command == "login":
115
+ run_login(args.url)
116
+ elif args.command == "fetch":
117
+ run_fetch(args.url)
118
+
119
+ if __name__ == "__main__":
120
+ main()
121
+
@@ -0,0 +1,9 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+ class Settings(BaseSettings):
4
+ app_name: str = "ContextPortal"
5
+ redis_url: str = "redis://localhost:6379"
6
+
7
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
8
+
9
+ settings = Settings()
@@ -0,0 +1 @@
1
+ # Core package
@@ -0,0 +1,234 @@
1
+ import asyncio
2
+ import time
3
+ import httpx
4
+ from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
5
+ from bs4 import BeautifulSoup
6
+ from readability import Document
7
+ import markdownify
8
+ from pydantic import BaseModel
9
+ from typing import Optional, Tuple
10
+
11
+ class ContextResult(BaseModel):
12
+ url: str
13
+ title: Optional[str] = None
14
+ content: str
15
+ content_type: str = "text/markdown"
16
+ retrieval_method: str
17
+ authenticated: bool
18
+
19
+ async def extract_markdown(html: str) -> Tuple[str, str]:
20
+ """Extracts main content from HTML and converts it to Markdown. Returns (markdown, title)."""
21
+ doc = Document(html)
22
+ title = doc.title()
23
+ main_html = doc.summary()
24
+ soup = BeautifulSoup(main_html, "lxml")
25
+ md_content = markdownify.markdownify(
26
+ str(soup),
27
+ heading_style="ATX",
28
+ strip=['script', 'style']
29
+ )
30
+ md_content = "\n".join([line for line in md_content.splitlines() if line.strip() or line == ""])
31
+ return md_content.strip(), title
32
+
33
+ USER_AGENT = "ContextPortal/0.1.0 (+https://github.com/NavadeepDj/ContextPortal)"
34
+
35
+ async def fetch_public(url: str) -> ContextResult | None:
36
+ """Attempts to fetch the URL normally. Returns ContextResult if successful and not blocked, else None."""
37
+ headers = {"User-Agent": USER_AGENT}
38
+ try:
39
+ async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=10.0) as client:
40
+ response = await client.get(url)
41
+
42
+ if response.status_code in (401, 403):
43
+ print("Public fetch hit 401/403.")
44
+ return None
45
+
46
+ response.raise_for_status()
47
+
48
+ final_url = str(response.url).lower()
49
+ if "login" in final_url or "signin" in final_url or "auth" in final_url:
50
+ print(f"Public fetch redirected to auth page: {final_url}")
51
+ return None
52
+
53
+ content_type = response.headers.get("content-type", "")
54
+ if "text/html" not in content_type:
55
+ return ContextResult(
56
+ url=final_url,
57
+ title=None,
58
+ content=response.text,
59
+ content_type=content_type,
60
+ retrieval_method="http",
61
+ authenticated=False
62
+ )
63
+
64
+ md, title = await extract_markdown(response.text)
65
+
66
+ if len(md.strip()) < 100:
67
+ print("Public fetch returned virtually empty content (likely an SPA shell).")
68
+ return None
69
+
70
+ return ContextResult(
71
+ url=final_url,
72
+ title=title,
73
+ content=md,
74
+ content_type="text/markdown",
75
+ retrieval_method="http",
76
+ authenticated=False
77
+ )
78
+ except Exception as e:
79
+ print(f"Public fetch failed: {e}")
80
+ return None
81
+
82
+ def _is_on_target_content(page, target_url: str) -> bool:
83
+ """Check if the page has navigated to (or near) the target URL's domain/path."""
84
+ from urllib.parse import urlparse
85
+ current = urlparse(page.url)
86
+ target = urlparse(target_url)
87
+ # Consider it "on target" if the domain matches the target domain
88
+ # and the URL no longer looks like a login/auth/access page.
89
+ current_url_lower = page.url.lower()
90
+ auth_indicators = ['login', 'signin', 'sign-in', 'auth', '/access']
91
+ is_auth_page = any(indicator in current_url_lower for indicator in auth_indicators)
92
+ return current.netloc == target.netloc and not is_auth_page
93
+
94
+
95
+ def _fetch_authenticated_sync(url: str) -> ContextResult:
96
+ """
97
+ Sync function that runs Playwright in a thread.
98
+ Uses sync_api to avoid the Windows asyncio subprocess bug.
99
+
100
+ Handles OAuth popup flows (e.g. Google Sign-In) by polling the
101
+ main page's URL rather than watching for DOM changes on the page,
102
+ since OAuth opens new windows that we can't inspect.
103
+ """
104
+ # Use a deterministic global directory for the user profile so it persists across different CWDs
105
+ from pathlib import Path
106
+ user_data_dir = str(Path.home() / ".contextportal" / "playwright_profile")
107
+
108
+ with sync_playwright() as p:
109
+ browser_context = p.chromium.launch_persistent_context(
110
+ user_data_dir,
111
+ headless=False,
112
+ channel="chrome",
113
+ viewport={"width": 1280, "height": 800}
114
+ )
115
+
116
+ page = browser_context.new_page()
117
+
118
+ try:
119
+ print(f"Navigating to {url}...")
120
+ page.goto(url, wait_until="domcontentloaded")
121
+
122
+ try:
123
+ page.wait_for_load_state("networkidle", timeout=5000)
124
+ except PlaywrightTimeoutError:
125
+ pass
126
+
127
+ # Detect if we landed on a login/auth page
128
+ current_url_lower = page.url.lower()
129
+ auth_indicators = ['login', 'signin', 'sign-in', 'auth', '/access']
130
+ has_password_field = page.evaluate(
131
+ '() => !!document.querySelector(\'input[type="password"]\')'
132
+ )
133
+ is_login_page = has_password_field or any(
134
+ indicator in current_url_lower for indicator in auth_indicators
135
+ )
136
+
137
+ if is_login_page:
138
+ print("Authentication required. Please log in using the opened browser window.")
139
+ print("This supports OAuth popups (Google, SSO, etc.) β€” complete login in any window that opens.")
140
+ print("Waiting for you to complete login (up to 5 minutes)...")
141
+
142
+ # Poll-based approach: check every 2 seconds if the main page
143
+ # has navigated back to the target domain after auth completion.
144
+ # This works with OAuth popups, SSO redirects, and multi-step flows
145
+ # where the auth happens in a separate window.
146
+ login_timeout_seconds = 300
147
+ poll_interval_seconds = 2
148
+ elapsed = 0
149
+ login_succeeded = False
150
+
151
+ while elapsed < login_timeout_seconds:
152
+ time.sleep(poll_interval_seconds)
153
+ elapsed += poll_interval_seconds
154
+
155
+ if _is_on_target_content(page, url):
156
+ print("Login detected! Page has returned to target domain.")
157
+ login_succeeded = True
158
+ break
159
+
160
+ if not login_succeeded:
161
+ # One final attempt: navigate back to the target URL.
162
+ # The browser context may now have valid session cookies
163
+ # even if the page itself didn't redirect back.
164
+ print("Login wait period ended. Attempting to navigate to the target resource...")
165
+ page.goto(url, wait_until="domcontentloaded")
166
+ try:
167
+ page.wait_for_load_state("networkidle", timeout=10000)
168
+ except PlaywrightTimeoutError:
169
+ pass
170
+
171
+ if not _is_on_target_content(page, url):
172
+ raise RuntimeError(
173
+ "Authentication timed out. Could not access the target resource. "
174
+ "Please try again and complete the login within 5 minutes."
175
+ )
176
+
177
+ # Wait for the page to fully settle after auth redirect
178
+ try:
179
+ page.wait_for_load_state("networkidle", timeout=10000)
180
+ except PlaywrightTimeoutError:
181
+ pass
182
+
183
+ # If we're on target domain but not the exact target URL, navigate there
184
+ if url not in page.url:
185
+ print(f"Redirecting to target resource: {url}")
186
+ page.goto(url, wait_until="domcontentloaded")
187
+ try:
188
+ page.wait_for_load_state("networkidle", timeout=10000)
189
+ except PlaywrightTimeoutError:
190
+ pass
191
+
192
+ print("Extracting content...")
193
+ html_content = page.content()
194
+ final_url = page.url
195
+
196
+ # Extract markdown synchronously here since we're in a thread
197
+ doc = Document(html_content)
198
+ title = doc.title()
199
+ main_html = doc.summary()
200
+ soup = BeautifulSoup(main_html, "lxml")
201
+ md_content = markdownify.markdownify(
202
+ str(soup),
203
+ heading_style="ATX",
204
+ strip=['script', 'style']
205
+ )
206
+ md_content = "\n".join([line for line in md_content.splitlines() if line.strip() or line == ""])
207
+
208
+ return ContextResult(
209
+ url=final_url,
210
+ title=title,
211
+ content=md_content.strip(),
212
+ content_type="text/markdown",
213
+ retrieval_method="browser",
214
+ authenticated=True
215
+ )
216
+
217
+ finally:
218
+ browser_context.close()
219
+
220
+ async def fetch_authenticated(url: str) -> ContextResult:
221
+ """Runs Playwright in a background thread to avoid Windows asyncio issues."""
222
+ return await asyncio.to_thread(_fetch_authenticated_sync, url)
223
+
224
+ async def get_context(url: str) -> ContextResult:
225
+ """Main entrypoint: tries public fetch, falls back to authenticated fetch."""
226
+ print(f"Attempting normal public fetch for: {url}")
227
+ public_result = await fetch_public(url)
228
+
229
+ if public_result:
230
+ print("Successfully retrieved publicly.")
231
+ return public_result
232
+
233
+ print("Public fetch failed or requires authentication. Falling back to authorized session...")
234
+ return await fetch_authenticated(url)
@@ -0,0 +1,41 @@
1
+ import ipaddress
2
+ from urllib.parse import urlparse
3
+
4
+ def validate_url_policy(url: str) -> None:
5
+ """
6
+ Validates that a URL is safe to fetch.
7
+ Rejects non-HTTP(S) schemes, localhost, and private/internal IP ranges.
8
+ Raises ValueError if the URL violates the policy.
9
+ """
10
+ if not url:
11
+ raise ValueError("URL cannot be empty")
12
+
13
+ parsed = urlparse(url)
14
+
15
+ # 1. Scheme Validation
16
+ if parsed.scheme not in ("http", "https"):
17
+ raise ValueError(f"Invalid scheme '{parsed.scheme}'. Only http and https are allowed.")
18
+
19
+ hostname = parsed.hostname
20
+ if not hostname:
21
+ raise ValueError("Invalid URL: missing hostname")
22
+
23
+ # 2. Block explicitly named localhosts
24
+ if hostname.lower() in ("localhost", "localhost.localdomain"):
25
+ raise ValueError("Access to localhost is forbidden by security policy")
26
+
27
+ # 3. IP Address Validation (block private/loopback/link-local)
28
+ try:
29
+ # Check if the hostname is directly an IP address
30
+ # Strip brackets for IPv6 compatibility if present
31
+ clean_hostname = hostname.strip("[]")
32
+ ip = ipaddress.ip_address(clean_hostname)
33
+ if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast or ip.is_unspecified:
34
+ raise ValueError(f"Access to internal/private IP ({hostname}) is forbidden by security policy")
35
+ except ValueError as e:
36
+ # Not an IP address string.
37
+ # Note: If a custom exception is raised from ipaddress parsing, we catch it.
38
+ # We re-raise our own ValueError if we blocked it inside the try block.
39
+ if "forbidden by security policy" in str(e):
40
+ raise
41
+
@@ -0,0 +1,32 @@
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.responses import PlainTextResponse
3
+ from pydantic import HttpUrl
4
+
5
+ from app.config import settings
6
+ from app.redis import check_redis_connection
7
+ from app.core.retriever import get_context
8
+
9
+ app = FastAPI(title=settings.app_name)
10
+
11
+ @app.get("/health")
12
+ async def health_check():
13
+ redis_connected = await check_redis_connection()
14
+ return {
15
+ "status": "ok",
16
+ "redis": "connected" if redis_connected else "disconnected"
17
+ }
18
+
19
+ @app.get("/c", response_class=PlainTextResponse)
20
+ async def fetch_context(url: HttpUrl):
21
+ """
22
+ MVP Endpoint: Agent requests a URL.
23
+ ContextPortal retrieves it (publicly or via authorized browser session)
24
+ and returns clean Markdown.
25
+ """
26
+ try:
27
+ result = await get_context(str(url))
28
+ if not result:
29
+ raise HTTPException(status_code=404, detail="Content could not be retrieved")
30
+ return result.content
31
+ except Exception as e:
32
+ raise HTTPException(status_code=500, detail=str(e))
File without changes
@@ -0,0 +1,50 @@
1
+ import asyncio
2
+ from mcp.server.mcpserver import MCPServer
3
+ import mcp.types as types
4
+ from app.core.security import validate_url_policy
5
+ from app.core.retriever import get_context
6
+
7
+ # Initialize the MCP Server
8
+ mcp = MCPServer("ContextPortal")
9
+
10
+ @mcp.tool()
11
+ async def fetch_context(url: str) -> str:
12
+ """Fetch context from a given URL.
13
+ Can retrieve content from authenticated/protected enterprise portals using the user's secure browser session.
14
+ """
15
+ if not url:
16
+ raise ValueError("url is required")
17
+
18
+ try:
19
+ # Enforce security policy before any retrieval
20
+ validate_url_policy(url)
21
+ except ValueError as e:
22
+ # Return a formatted error message so the agent understands why it was blocked.
23
+ # Returning a string prefixed with 'Error:' is a common pattern for LLMs
24
+ # when we can't directly override the is_error flag in the high-level API.
25
+ return f"Error: {str(e)}"
26
+
27
+ # Delegate entirely to the retrieval orchestrator
28
+ try:
29
+ result = await get_context(url)
30
+ if not result:
31
+ return "Error: Could not retrieve content from the URL. The page might be empty, heavily obfuscated, or the auth session may have expired."
32
+
33
+ formatted_response = (
34
+ f"# {result.title or 'Untitled Document'}\n"
35
+ f"**Source URL**: {result.url}\n"
36
+ f"**Retrieval Method**: {result.retrieval_method} (Authenticated: {result.authenticated})\n"
37
+ f"---\n\n"
38
+ f"{result.content}"
39
+ )
40
+ return formatted_response
41
+ except Exception as e:
42
+ return f"Error retrieving context: {str(e)}"
43
+
44
+ def main():
45
+ """Run the MCP server over STDIO transport."""
46
+ # MCPServer provides a synchronous run method that handles asyncio internally
47
+ mcp.run()
48
+
49
+ if __name__ == "__main__":
50
+ main()
@@ -0,0 +1,11 @@
1
+ import redis.asyncio as redis
2
+ from app.config import settings
3
+
4
+ redis_client = redis.from_url(settings.redis_url, decode_responses=True)
5
+
6
+ async def check_redis_connection() -> bool:
7
+ try:
8
+ await redis_client.ping()
9
+ return True
10
+ except redis.ConnectionError:
11
+ return False
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: contextportal
3
+ Version: 0.1.0
4
+ Summary: The authenticated fetch layer for AI agents.
5
+ Author: NavadeepDj
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/NavadeepDj/ContextPortal
8
+ Project-URL: Repository, https://github.com/NavadeepDj/ContextPortal
9
+ Project-URL: Issues, https://github.com/NavadeepDj/ContextPortal/issues
10
+ Keywords: mcp,ai-agents,retrieval,browser-automation,context-proxy,security
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: fastapi>=0.115.0
21
+ Requires-Dist: uvicorn[standard]>=0.30.0
22
+ Requires-Dist: redis[hiredis]>=5.0.0
23
+ Requires-Dist: pydantic-settings>=2.4.0
24
+ Requires-Dist: httpx>=0.27.0
25
+ Requires-Dist: playwright>=1.62.0
26
+ Requires-Dist: readability-lxml>=0.8.4.1
27
+ Requires-Dist: markdownify>=1.2.3
28
+ Requires-Dist: mcp[cli]>=2.1.1
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
31
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
32
+ Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
33
+ Requires-Dist: ruff>=0.6.0; extra == "dev"
34
+ Requires-Dist: mypy>=1.11.0; extra == "dev"
35
+ Requires-Dist: twine>=5.0.0; extra == "dev"
36
+
37
+ # πŸ”‘ ContextPortal
38
+
39
+ > **Give your AI agent access to authenticated web pages β€” without giving it your credentials.**
40
+
41
+ Your AI agent can fetch any public webpage. But the moment it hits a login wall β€” Jira, Confluence, internal wikis, enterprise dashboards β€” it's stuck.
42
+
43
+ ContextPortal sits between your AI agent and the web. When the agent needs a protected page, ContextPortal uses *your* existing browser session to grab it, strips out all the noise, and hands back clean Markdown. The agent gets context. It never gets your cookies.
44
+
45
+ > πŸͺ *Agent: "Can I have your cookies?"*
46
+ > 🚫 *ContextPortal: "No. Here's the page."*
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ uv tool install contextportal
52
+ ```
53
+
54
+ ## Setup
55
+
56
+ ```bash
57
+ # Log into your private sites (once)
58
+ contextportal login
59
+
60
+ # Test it
61
+ contextportal fetch https://your-protected-site.com/docs
62
+ ```
63
+
64
+ ## Connect to your AI Agent
65
+
66
+ ```json
67
+ {
68
+ "mcpServers": {
69
+ "context-portal": {
70
+ "command": "contextportal",
71
+ "args": ["mcp"]
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ Works with **Cursor**, **Claude Desktop**, **Antigravity**, **VS Code**, and any MCP-compatible client.
78
+
79
+ ## Learn More
80
+
81
+ - πŸ“– [Full Documentation](https://github.com/NavadeepDj/ContextPortal)
82
+ - πŸ” [Security Model](https://github.com/NavadeepDj/ContextPortal/blob/main/docs/security-and-automation-philosophy.md)
83
+ - πŸ—ΊοΈ [Product Roadmap](https://github.com/NavadeepDj/ContextPortal/blob/main/PRODUCT_ROADMAP.md)
@@ -0,0 +1,23 @@
1
+ README.md
2
+ pyproject.toml
3
+ app/__init__.py
4
+ app/cli.py
5
+ app/config.py
6
+ app/main.py
7
+ app/redis.py
8
+ app/core/__init__.py
9
+ app/core/retriever.py
10
+ app/core/security.py
11
+ app/mcp/__init__.py
12
+ app/mcp/server.py
13
+ contextportal.egg-info/PKG-INFO
14
+ contextportal.egg-info/SOURCES.txt
15
+ contextportal.egg-info/dependency_links.txt
16
+ contextportal.egg-info/entry_points.txt
17
+ contextportal.egg-info/requires.txt
18
+ contextportal.egg-info/top_level.txt
19
+ tests/test_api.py
20
+ tests/test_health.py
21
+ tests/test_mcp.py
22
+ tests/test_retriever.py
23
+ tests/test_security.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ contextportal = app.cli:main
@@ -0,0 +1,17 @@
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ redis[hiredis]>=5.0.0
4
+ pydantic-settings>=2.4.0
5
+ httpx>=0.27.0
6
+ playwright>=1.62.0
7
+ readability-lxml>=0.8.4.1
8
+ markdownify>=1.2.3
9
+ mcp[cli]>=2.1.1
10
+
11
+ [dev]
12
+ pytest>=8.0.0
13
+ pytest-asyncio>=0.24.0
14
+ pytest-cov>=5.0.0
15
+ ruff>=0.6.0
16
+ mypy>=1.11.0
17
+ twine>=5.0.0
@@ -0,0 +1,75 @@
1
+ [project]
2
+ name = "contextportal"
3
+ version = "0.1.0"
4
+ description = "The authenticated fetch layer for AI agents."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [
9
+ { name = "NavadeepDj" }
10
+ ]
11
+ keywords = [
12
+ "mcp",
13
+ "ai-agents",
14
+ "retrieval",
15
+ "browser-automation",
16
+ "context-proxy",
17
+ "security"
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Operating System :: OS Independent",
27
+ ]
28
+ dependencies = [
29
+ "fastapi>=0.115.0",
30
+ "uvicorn[standard]>=0.30.0",
31
+ "redis[hiredis]>=5.0.0",
32
+ "pydantic-settings>=2.4.0",
33
+ "httpx>=0.27.0",
34
+ "playwright>=1.62.0",
35
+ "readability-lxml>=0.8.4.1",
36
+ "markdownify>=1.2.3",
37
+ "mcp[cli]>=2.1.1",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/NavadeepDj/ContextPortal"
42
+ Repository = "https://github.com/NavadeepDj/ContextPortal"
43
+ Issues = "https://github.com/NavadeepDj/ContextPortal/issues"
44
+
45
+ [project.scripts]
46
+ contextportal = "app.cli:main"
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["."]
50
+ include = ["app", "app.*"]
51
+
52
+ [project.optional-dependencies]
53
+ dev = [
54
+ "pytest>=8.0.0",
55
+ "pytest-asyncio>=0.24.0",
56
+ "pytest-cov>=5.0.0",
57
+ "ruff>=0.6.0",
58
+ "mypy>=1.11.0",
59
+ "twine>=5.0.0",
60
+ ]
61
+
62
+ [tool.pytest.ini_options]
63
+ asyncio_mode = "auto"
64
+ testpaths = ["tests"]
65
+
66
+ [tool.ruff]
67
+ target-version = "py312"
68
+ line-length = 88
69
+
70
+ [tool.ruff.lint]
71
+ select = ["E", "F", "I", "N", "W", "UP"]
72
+
73
+ [tool.mypy]
74
+ python_version = "3.12"
75
+ strict = true
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,44 @@
1
+ import pytest
2
+ from unittest.mock import patch
3
+ from httpx import AsyncClient, ASGITransport
4
+ from app.main import app
5
+ from app.core.retriever import ContextResult
6
+
7
+
8
+ @pytest.mark.asyncio
9
+ @patch("app.main.get_context")
10
+ async def test_fetch_context_endpoint_success(mock_get_context):
11
+ mock_get_context.return_value = ContextResult(
12
+ url="https://example.com/article",
13
+ title="Extracted Article Content",
14
+ content="# Extracted Article Content\n\nThis is sample markdown.",
15
+ retrieval_method="http",
16
+ authenticated=False,
17
+ )
18
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
19
+ response = await ac.get("/c?url=https://example.com/article")
20
+
21
+ assert response.status_code == 200
22
+ assert response.headers["content-type"].startswith("text/plain")
23
+ assert response.text == "# Extracted Article Content\n\nThis is sample markdown."
24
+ mock_get_context.assert_called_once_with("https://example.com/article")
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_fetch_context_endpoint_invalid_url():
29
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
30
+ response = await ac.get("/c?url=not-a-valid-url")
31
+
32
+ assert response.status_code == 422
33
+
34
+
35
+ @pytest.mark.asyncio
36
+ @patch("app.main.get_context")
37
+ async def test_fetch_context_endpoint_error(mock_get_context):
38
+ mock_get_context.side_effect = Exception("Retrieval failed")
39
+
40
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
41
+ response = await ac.get("/c?url=https://example.com/fails")
42
+
43
+ assert response.status_code == 500
44
+ assert response.json()["detail"] == "Retrieval failed"
@@ -0,0 +1,12 @@
1
+ import pytest
2
+ from httpx import AsyncClient, ASGITransport
3
+ from app.main import app
4
+
5
+ @pytest.mark.asyncio
6
+ async def test_health_check_redis_disconnected():
7
+ # Because we're not running redis in this basic unit test, it will probably report disconnected
8
+ # but the endpoint should still return 200 OK.
9
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
10
+ response = await ac.get("/health")
11
+ assert response.status_code == 200
12
+ assert response.json()["status"] == "ok"
@@ -0,0 +1,80 @@
1
+ import pytest
2
+ import asyncio
3
+ from unittest.mock import patch
4
+ from mcp.server.models import InitializationOptions
5
+ from mcp.shared.memory import create_client_server_memory_streams
6
+ from mcp.client.session import ClientSession
7
+ from app.mcp.server import mcp
8
+
9
+ from unittest.mock import patch, AsyncMock
10
+ from app.core.retriever import ContextResult
11
+
12
+ @pytest.fixture
13
+ def mock_get_context():
14
+ with patch("app.mcp.server.get_context", new_callable=AsyncMock) as mock:
15
+ mock.return_value = ContextResult(
16
+ url="https://example.com/redirected",
17
+ title="Example Title",
18
+ content="# Mocked Content",
19
+ retrieval_method="http",
20
+ authenticated=False
21
+ )
22
+ yield mock
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_mcp_fetch_context(mock_get_context):
26
+ # 1. Create in-memory streams for client and server communication
27
+ async with create_client_server_memory_streams() as (client_streams, server_streams):
28
+ # 2. Get the low-level Server instance from the MCPServer wrapper
29
+ server = mcp._lowlevel_server
30
+
31
+ init_options = InitializationOptions(
32
+ server_name="ContextPortal",
33
+ server_version="0.1.0",
34
+ capabilities=server.get_capabilities(
35
+ notification_options=None,
36
+ experimental_capabilities={}
37
+ )
38
+ )
39
+
40
+ # 3. Start the server run loop in a background task
41
+ server_task = asyncio.create_task(
42
+ server.run(server_streams[0], server_streams[1], init_options)
43
+ )
44
+
45
+ try:
46
+ # 4. Create and initialize the client session
47
+ async with ClientSession(client_streams[0], client_streams[1]) as session:
48
+ await session.initialize()
49
+
50
+ # 5. List tools and verify our tool is registered
51
+ tools_response = await session.list_tools()
52
+ assert len(tools_response.tools) == 1
53
+ assert tools_response.tools[0].name == "fetch_context"
54
+
55
+ # 6. Call the tool and verify it delegates to get_context
56
+ result = await session.call_tool("fetch_context", {"url": "https://example.com"})
57
+ assert not result.is_error
58
+ assert len(result.content) == 1
59
+ assert result.content[0].type == "text"
60
+ assert "# Example Title" in result.content[0].text
61
+ assert "**Source URL**: https://example.com/redirected" in result.content[0].text
62
+ assert "**Retrieval Method**: http" in result.content[0].text
63
+ assert "# Mocked Content" in result.content[0].text
64
+
65
+ mock_get_context.assert_called_once_with("https://example.com")
66
+
67
+ # 7. Verify security policy enforcement over MCP
68
+ result_local = await session.call_tool("fetch_context", {"url": "http://localhost:8000"})
69
+ # We return the error as a readable string to the LLM
70
+ assert "Error: Access to localhost is forbidden by security policy" in result_local.content[0].text
71
+
72
+ result_file = await session.call_tool("fetch_context", {"url": "file:///etc/passwd"})
73
+ assert "Error: Invalid scheme" in result_file.content[0].text
74
+ finally:
75
+ # Clean up the background server task
76
+ server_task.cancel()
77
+ try:
78
+ await server_task
79
+ except asyncio.CancelledError:
80
+ pass
@@ -0,0 +1,229 @@
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock, PropertyMock
3
+ from app.core.retriever import extract_markdown, fetch_public, get_context, fetch_authenticated, ContextResult
4
+
5
+
6
+ @pytest.mark.asyncio
7
+ async def test_extract_markdown():
8
+ html = """
9
+ <html>
10
+ <head><title>Test Document</title></head>
11
+ <body>
12
+ <script>console.log('Malicious or tracker script');</script>
13
+ <h1>Main Title</h1>
14
+ <p>This is a paragraph with <strong>bold</strong> text.</p>
15
+ <style>body { color: red; }</style>
16
+ </body>
17
+ </html>
18
+ """
19
+ md, title = await extract_markdown(html)
20
+ assert "# Main Title" in md
21
+ assert "This is a paragraph with **bold** text." in md
22
+ assert "Malicious or tracker script" not in md
23
+ assert "color: red;" not in md
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ @patch("httpx.AsyncClient.get")
28
+ async def test_fetch_public_success(mock_get):
29
+ mock_response = MagicMock()
30
+ mock_response.status_code = 200
31
+ mock_response.url = "https://example.com/article"
32
+ mock_response.headers = {"content-type": "text/html; charset=utf-8"}
33
+ mock_response.text = "<html><body><h1>Sample Article</h1><p>Here is a detailed article with plenty of content to pass the minimum character count check.</p></body></html>"
34
+ mock_get.return_value = mock_response
35
+
36
+ result = await fetch_public("https://example.com/article")
37
+ assert result is not None
38
+ assert result.retrieval_method == "http"
39
+ assert "# Sample Article" in result.content
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ @patch("httpx.AsyncClient.get")
44
+ async def test_fetch_public_unauthorized_status(mock_get):
45
+ mock_response = MagicMock()
46
+ mock_response.status_code = 401
47
+ mock_get.return_value = mock_response
48
+
49
+ result = await fetch_public("https://example.com/protected")
50
+ assert result is None
51
+
52
+
53
+ @pytest.mark.asyncio
54
+ @patch("httpx.AsyncClient.get")
55
+ async def test_fetch_public_forbidden_status(mock_get):
56
+ mock_response = MagicMock()
57
+ mock_response.status_code = 403
58
+ mock_get.return_value = mock_response
59
+
60
+ result = await fetch_public("https://example.com/forbidden")
61
+ assert result is None
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ @patch("httpx.AsyncClient.get")
66
+ async def test_fetch_public_auth_redirect(mock_get):
67
+ mock_response = MagicMock()
68
+ mock_response.status_code = 200
69
+ mock_response.url = "https://example.com/login?redirect=/protected"
70
+ mock_response.headers = {"content-type": "text/html"}
71
+ mock_response.text = "<html><body><h1>Login</h1></body></html>"
72
+ mock_get.return_value = mock_response
73
+
74
+ result = await fetch_public("https://example.com/protected")
75
+ assert result is None
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ @patch("httpx.AsyncClient.get")
80
+ async def test_fetch_public_empty_spa_shell(mock_get):
81
+ mock_response = MagicMock()
82
+ mock_response.status_code = 200
83
+ mock_response.url = "https://example.com/app"
84
+ mock_response.headers = {"content-type": "text/html"}
85
+ mock_response.text = "<html><body><div id='root'></div></body></html>"
86
+ mock_get.return_value = mock_response
87
+
88
+ result = await fetch_public("https://example.com/app")
89
+ assert result is None
90
+
91
+
92
+ @pytest.mark.asyncio
93
+ @patch("httpx.AsyncClient.get")
94
+ async def test_fetch_public_non_html(mock_get):
95
+ mock_response = MagicMock()
96
+ mock_response.status_code = 200
97
+ mock_response.url = "https://example.com/data.json"
98
+ mock_response.headers = {"content-type": "application/json"}
99
+ mock_response.text = '{"status": "ok", "message": "hello"}'
100
+ mock_get.return_value = mock_response
101
+
102
+ result = await fetch_public("https://example.com/data.json")
103
+ assert result.content == '{"status": "ok", "message": "hello"}'
104
+
105
+
106
+ @pytest.mark.asyncio
107
+ @patch("httpx.AsyncClient.get")
108
+ async def test_fetch_public_exception(mock_get):
109
+ mock_get.side_effect = Exception("Connection timed out")
110
+
111
+ result = await fetch_public("https://unreachable.example.com")
112
+ assert result is None
113
+
114
+
115
+ @pytest.mark.asyncio
116
+ @patch("app.core.retriever.fetch_public")
117
+ @patch("app.core.retriever.fetch_authenticated")
118
+ async def test_get_context_public_branch(mock_fetch_auth, mock_fetch_public):
119
+ from app.core.retriever import ContextResult
120
+ mock_fetch_public.return_value = ContextResult(url="https://example.com", title=None, content="# Public Markdown", retrieval_method="http", authenticated=False)
121
+
122
+ result = await get_context("https://example.com")
123
+ assert result.content == "# Public Markdown"
124
+ mock_fetch_public.assert_called_once_with("https://example.com")
125
+ mock_fetch_auth.assert_not_called()
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ @patch("app.core.retriever.fetch_public")
130
+ @patch("app.core.retriever.fetch_authenticated")
131
+ async def test_get_context_fallback_to_authenticated(mock_fetch_auth, mock_fetch_public):
132
+ mock_fetch_public.return_value = None
133
+ mock_fetch_auth.return_value = ContextResult(url="https://example.com/protected", title=None, content="# Authenticated Context", retrieval_method="browser", authenticated=True)
134
+
135
+ result = await get_context("https://example.com/protected")
136
+ assert result.content == "# Authenticated Context"
137
+ mock_fetch_public.assert_called_once_with("https://example.com/protected")
138
+ mock_fetch_auth.assert_called_once_with("https://example.com/protected")
139
+
140
+
141
+ @pytest.mark.asyncio
142
+ @patch("app.core.retriever._fetch_authenticated_sync")
143
+ async def test_fetch_authenticated_thread_delegation(mock_sync_fetch):
144
+ mock_sync_fetch.return_value = ContextResult(url="https://example.com/protected", title=None, content="# Auth Markdown", retrieval_method="browser", authenticated=True)
145
+ result = await fetch_authenticated("https://example.com/protected")
146
+ assert result.content == "# Auth Markdown"
147
+ mock_sync_fetch.assert_called_once_with("https://example.com/protected")
148
+
149
+
150
+ @patch("app.core.retriever.sync_playwright")
151
+ def test_fetch_authenticated_sync_already_authenticated(mock_sync_playwright):
152
+ from app.core.retriever import _fetch_authenticated_sync
153
+
154
+ mock_p = MagicMock()
155
+ mock_browser_context = MagicMock()
156
+ mock_page = MagicMock()
157
+
158
+ mock_sync_playwright.return_value.__enter__.return_value = mock_p
159
+ mock_p.chromium.launch_persistent_context.return_value = mock_browser_context
160
+ mock_browser_context.new_page.return_value = mock_page
161
+
162
+ # Not a login page β€” URL is the target domain, no auth indicators
163
+ mock_page.url = "https://example.com/dashboard"
164
+ mock_page.evaluate.return_value = False
165
+ mock_page.content.return_value = "<html><body><h1>Dashboard</h1><p>Authorized user info</p></body></html>"
166
+
167
+ result = _fetch_authenticated_sync("https://example.com/dashboard")
168
+
169
+ assert "# Dashboard" in result.content
170
+ assert "Authorized user info" in result.content
171
+ mock_browser_context.close.assert_called_once()
172
+
173
+
174
+ @patch("app.core.retriever.time")
175
+ @patch("app.core.retriever.sync_playwright")
176
+ def test_fetch_authenticated_sync_with_login_flow(mock_sync_playwright, mock_time):
177
+ from app.core.retriever import _fetch_authenticated_sync
178
+
179
+ mock_p = MagicMock()
180
+ mock_browser_context = MagicMock()
181
+ mock_page = MagicMock()
182
+
183
+ mock_sync_playwright.return_value.__enter__.return_value = mock_p
184
+ mock_p.chromium.launch_persistent_context.return_value = mock_browser_context
185
+ mock_browser_context.new_page.return_value = mock_page
186
+
187
+ # Initially lands on a login/auth page
188
+ mock_page.evaluate.return_value = True
189
+ mock_page.content.return_value = "<html><body><h1>Protected Course</h1><p>Welcome student!</p></body></html>"
190
+
191
+ # Simulate: first poll still on auth page, second poll back on target
192
+ mock_page.url = "https://app.joinhandshake.com/access?auth=true"
193
+ url_sequence = [
194
+ "https://app.joinhandshake.com/access?auth=true", # 1st poll β€” still on auth
195
+ "https://project-dynamo.learn.joinhandshake.com/introduction", # 2nd poll β€” landed!
196
+ ]
197
+ type(mock_page).url = PropertyMock(side_effect=url_sequence + ["https://project-dynamo.learn.joinhandshake.com/introduction"] * 10)
198
+
199
+ result = _fetch_authenticated_sync("https://project-dynamo.learn.joinhandshake.com/introduction")
200
+
201
+ assert "# Protected Course" in result.content
202
+ assert "Welcome student!" in result.content
203
+ mock_browser_context.close.assert_called_once()
204
+
205
+
206
+ @patch("app.core.retriever.time")
207
+ @patch("app.core.retriever.sync_playwright")
208
+ def test_fetch_authenticated_sync_timeout_raises_error(mock_sync_playwright, mock_time):
209
+ from app.core.retriever import _fetch_authenticated_sync
210
+
211
+ mock_p = MagicMock()
212
+ mock_browser_context = MagicMock()
213
+ mock_page = MagicMock()
214
+
215
+ mock_sync_playwright.return_value.__enter__.return_value = mock_p
216
+ mock_p.chromium.launch_persistent_context.return_value = mock_browser_context
217
+ mock_browser_context.new_page.return_value = mock_page
218
+
219
+ # Stuck on auth page forever
220
+ mock_page.evaluate.return_value = True
221
+ mock_page.url = "https://app.joinhandshake.com/access?auth=true"
222
+
223
+ with pytest.raises(RuntimeError, match="Authentication timed out"):
224
+ _fetch_authenticated_sync("https://project-dynamo.learn.joinhandshake.com/introduction")
225
+
226
+ mock_browser_context.close.assert_called_once()
227
+
228
+
229
+
@@ -0,0 +1,49 @@
1
+ import pytest
2
+ from app.core.security import validate_url_policy
3
+
4
+ def test_validate_url_policy_allowed():
5
+ # Public URLs should pass
6
+ validate_url_policy("https://example.com")
7
+ validate_url_policy("http://public-api.com/data")
8
+ validate_url_policy("https://project-dynamo.learn.joinhandshake.com/introduction")
9
+
10
+ def test_validate_url_policy_rejected_schemes():
11
+ # Only http and https are allowed
12
+ with pytest.raises(ValueError, match="Invalid scheme"):
13
+ validate_url_policy("file:///etc/passwd")
14
+
15
+ with pytest.raises(ValueError, match="Invalid scheme"):
16
+ validate_url_policy("javascript:alert(1)")
17
+
18
+ with pytest.raises(ValueError, match="Invalid scheme"):
19
+ validate_url_policy("data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==")
20
+
21
+ with pytest.raises(ValueError, match="Invalid scheme"):
22
+ validate_url_policy("ftp://example.com/file")
23
+
24
+ def test_validate_url_policy_rejected_localhost():
25
+ # Localhost strings are blocked
26
+ with pytest.raises(ValueError, match="forbidden by security policy"):
27
+ validate_url_policy("http://localhost:8000/api")
28
+
29
+ with pytest.raises(ValueError, match="forbidden by security policy"):
30
+ validate_url_policy("https://localhost.localdomain")
31
+
32
+ # Localhost loopback IPs are blocked
33
+ with pytest.raises(ValueError, match="forbidden by security policy"):
34
+ validate_url_policy("https://127.0.0.1")
35
+
36
+ with pytest.raises(ValueError, match="forbidden by security policy"):
37
+ validate_url_policy("http://[::1]/")
38
+
39
+ def test_validate_url_policy_rejected_private_ips():
40
+ # RFC 1918 private networks are blocked
41
+ with pytest.raises(ValueError, match="forbidden by security policy"):
42
+ validate_url_policy("http://192.168.1.1/admin")
43
+
44
+ with pytest.raises(ValueError, match="forbidden by security policy"):
45
+ validate_url_policy("http://10.0.0.5")
46
+
47
+ with pytest.raises(ValueError, match="forbidden by security policy"):
48
+ validate_url_policy("https://172.16.0.100")
49
+