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.
- contextportal-0.1.0/PKG-INFO +83 -0
- contextportal-0.1.0/README.md +47 -0
- contextportal-0.1.0/app/__init__.py +1 -0
- contextportal-0.1.0/app/cli.py +121 -0
- contextportal-0.1.0/app/config.py +9 -0
- contextportal-0.1.0/app/core/__init__.py +1 -0
- contextportal-0.1.0/app/core/retriever.py +234 -0
- contextportal-0.1.0/app/core/security.py +41 -0
- contextportal-0.1.0/app/main.py +32 -0
- contextportal-0.1.0/app/mcp/__init__.py +0 -0
- contextportal-0.1.0/app/mcp/server.py +50 -0
- contextportal-0.1.0/app/redis.py +11 -0
- contextportal-0.1.0/contextportal.egg-info/PKG-INFO +83 -0
- contextportal-0.1.0/contextportal.egg-info/SOURCES.txt +23 -0
- contextportal-0.1.0/contextportal.egg-info/dependency_links.txt +1 -0
- contextportal-0.1.0/contextportal.egg-info/entry_points.txt +2 -0
- contextportal-0.1.0/contextportal.egg-info/requires.txt +17 -0
- contextportal-0.1.0/contextportal.egg-info/top_level.txt +1 -0
- contextportal-0.1.0/pyproject.toml +75 -0
- contextportal-0.1.0/setup.cfg +4 -0
- contextportal-0.1.0/tests/test_api.py +44 -0
- contextportal-0.1.0/tests/test_health.py +12 -0
- contextportal-0.1.0/tests/test_mcp.py +80 -0
- contextportal-0.1.0/tests/test_retriever.py +229 -0
- contextportal-0.1.0/tests/test_security.py +49 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
app
|
|
@@ -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,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
|
+
|