diff --git a/mcp/server.py b/mcp/server.py index 672e555..4cb7816 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -1,10 +1,11 @@ from fastmcp import FastMCP -from fastmcp.server.auth.providers.auth0 import Auth0Provider +from fastmcp.server.dependencies import get_http_headers from psycopg.rows import dict_row import cohere import dotenv import psycopg import os +import json dotenv.load_dotenv('/home/pew/scripts-private/loser/cavepedia-v2/poller.env') @@ -20,19 +21,21 @@ conn = psycopg.connect( row_factory=dict_row, ) - -# The Auth0Provider utilizes Auth0 OIDC configuration -auth_provider = Auth0Provider( - config_url="https://dev-jao4so0av61ny4mr.us.auth0.com/.well-known/openid-configuration", - client_id="oONcxma5PNFwYLhrDC4o0PUuAmqDekzM", - client_secret="4Z7Wl12ALEtDmNAoERQe7lK2YD9x6jz7H25FiMxRp518dnag-IS2NLLScnmbe4-b", - audience="https://dev-jao4so0av61ny4mr.us.auth0.com/me/", - base_url="https://mcp.caving.dev", - # redirect_path="/auth/callback" # Default value, customize if needed -) - mcp = FastMCP("Cavepedia MCP") +def get_user_roles() -> list[str]: + """Extract user roles from the X-User-Roles header.""" + headers = get_http_headers() + print(f"[MCP] All headers: {dict(headers)}") + roles_header = headers.get("x-user-roles", "") + print(f"[MCP] X-User-Roles header: {roles_header}") + if roles_header: + try: + return json.loads(roles_header) + except json.JSONDecodeError: + return [] + return [] + def embed(text, input_type): resp = co.embed( texts=[text], @@ -54,25 +57,23 @@ def search(query) -> list[dict]: @mcp.tool def get_cave_location(cave: str, state: str, county: str) -> list[dict]: """Lookup cave location as coordinates. Returns up to 5 matches, ordered by most to least relevant.""" + roles = get_user_roles() + print(f"get_cave_location called with roles: {roles}") return search(f'{cave} Location, latitude, Longitude. Located in {state} and {county} county.') @mcp.tool def general_caving_information(query: str) -> list[dict]: - """General purpose endpoint for any topic related to caves. Returns up to 5 mates, orderd by most to least relevant.""" + """General purpose endpoint for any topic related to caves. Returns up to 5 matches, ordered by most to least relevant.""" + roles = get_user_roles() + print(f"general_caving_information called with roles: {roles}") return search(query) -# Add a protected tool to test authentication @mcp.tool -async def get_token_info() -> dict: - """Returns information about the Auth0 token.""" - from fastmcp.server.dependencies import get_access_token - - token = get_access_token() - +def get_user_info() -> dict: + """Get information about the current user's roles.""" + roles = get_user_roles() return { - "issuer": token.claims.get("iss"), - "audience": token.claims.get("aud"), - "scope": token.claims.get("scope") + "roles": roles, } if __name__ == "__main__": diff --git a/web/agent/main.py b/web/agent/main.py index d4998ac..bfce658 100644 --- a/web/agent/main.py +++ b/web/agent/main.py @@ -3,7 +3,8 @@ This is the main entry point for the agent. It defines the workflow graph, state, tools, nodes and edges. """ -from typing import Any, List +from typing import Any, List, Callable, Awaitable +import json from langchain.tools import tool from langchain_core.messages import BaseMessage, SystemMessage @@ -13,6 +14,7 @@ from langgraph.graph import END, MessagesState, StateGraph, START from langgraph.prebuilt import ToolNode, tools_condition from langgraph.types import Command from langchain_mcp_adapters.client import MultiServerMCPClient +from langchain_mcp_adapters.interceptors import MCPToolCallRequest, MCPToolCallResult class AgentState(MessagesState): @@ -37,41 +39,55 @@ backend_tools = [ # your_tool_here ] -def get_mcp_client(access_token: str = None): - """Create MCP client with optional authentication headers.""" - headers = {} - if access_token: - headers["Authorization"] = f"Bearer {access_token}" +class RolesHeaderInterceptor: + """Interceptor that injects user roles header into MCP tool calls.""" + def __init__(self, user_roles: list = None): + self.user_roles = user_roles or [] + + async def __call__( + self, + request: MCPToolCallRequest, + handler: Callable[[MCPToolCallRequest], Awaitable[MCPToolCallResult]] + ) -> MCPToolCallResult: + headers = dict(request.headers or {}) + if self.user_roles: + headers["X-User-Roles"] = json.dumps(self.user_roles) + + modified_request = request.override(headers=headers) + return await handler(modified_request) + +def get_mcp_client(user_roles: list = None): + """Create MCP client with user roles header.""" return MultiServerMCPClient( { "cavepedia": { "transport": "streamable_http", "url": "https://mcp.caving.dev/mcp", "timeout": 10.0, - "headers": headers, } - } + }, + tool_interceptors=[RolesHeaderInterceptor(user_roles)] ) # Cache for MCP tools per access token _mcp_tools_cache = {} -async def get_mcp_tools(access_token: str = None): - """Lazy load MCP tools with authentication.""" - cache_key = access_token or "default" +async def get_mcp_tools(user_roles: list = None): + """Lazy load MCP tools with user roles.""" + roles_key = ",".join(sorted(user_roles)) if user_roles else "default" - if cache_key not in _mcp_tools_cache: + if roles_key not in _mcp_tools_cache: try: - mcp_client = get_mcp_client(access_token) + mcp_client = get_mcp_client(user_roles) tools = await mcp_client.get_tools() - _mcp_tools_cache[cache_key] = tools - print(f"Loaded {len(tools)} tools from MCP server with auth: {bool(access_token)}") + _mcp_tools_cache[roles_key] = tools + print(f"Loaded {len(tools)} tools from MCP server with roles: {user_roles}") except Exception as e: print(f"Warning: Failed to load MCP tools: {e}") - _mcp_tools_cache[cache_key] = [] + _mcp_tools_cache[roles_key] = [] - return _mcp_tools_cache[cache_key] + return _mcp_tools_cache[roles_key] async def chat_node(state: AgentState, config: RunnableConfig) -> dict: @@ -86,18 +102,18 @@ async def chat_node(state: AgentState, config: RunnableConfig) -> dict: https://www.perplexity.ai/search/react-agents-NcXLQhreS0WDzpVaS4m9Cg """ - # 0. Extract Auth0 access token from config + # 0. Extract user roles from config.configurable.context configurable = config.get("configurable", {}) - access_token = configurable.get("auth0_access_token") - user_roles = configurable.get("auth0_user_roles", []) + context = configurable.get("context", {}) + user_roles = context.get("auth0_user_roles", []) - print(f"Chat node invoked with auth token: {bool(access_token)}, roles: {user_roles}") + print(f"Chat node invoked with roles: {user_roles}") # 1. Define the model model = ChatAnthropic(model="claude-sonnet-4-5-20250929") - # 1.5 Load MCP tools from the cavepedia server with authentication - mcp_tools = await get_mcp_tools(access_token) + # 1.5 Load MCP tools from the cavepedia server with roles + mcp_tools = await get_mcp_tools(user_roles) # 2. Bind the tools to the model model_with_tools = model.bind_tools( @@ -135,12 +151,13 @@ async def tool_node_wrapper(state: AgentState, config: RunnableConfig) -> dict: """ Custom tool node that handles both backend tools and MCP tools. """ - # Extract Auth0 access token from config + # Extract user roles from config.configurable.context configurable = config.get("configurable", {}) - access_token = configurable.get("auth0_access_token") + context = configurable.get("context", {}) + user_roles = context.get("auth0_user_roles", []) - # Load MCP tools with authentication and combine with backend tools - mcp_tools = await get_mcp_tools(access_token) + # Load MCP tools with roles + mcp_tools = await get_mcp_tools(user_roles) all_tools = [*backend_tools, *mcp_tools] # Use the standard ToolNode with all tools diff --git a/web/agent/pyproject.toml b/web/agent/pyproject.toml index 347eb4b..294acdc 100644 --- a/web/agent/pyproject.toml +++ b/web/agent/pyproject.toml @@ -14,4 +14,6 @@ dependencies = [ "langgraph-cli[inmem]>=0.4.7", "langchain-anthropic>=0.3.3", "langchain-mcp-adapters>=0.1.0", + "docstring-parser>=0.17.0", + "jsonschema>=4.25.1", ] diff --git a/web/agent/uv.lock b/web/agent/uv.lock index 423f63a..5cf3e73 100644 --- a/web/agent/uv.lock +++ b/web/agent/uv.lock @@ -1362,7 +1362,9 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "anthropic" }, + { name = "docstring-parser" }, { name = "fastapi" }, + { name = "jsonschema" }, { name = "langchain" }, { name = "langchain-anthropic" }, { name = "langchain-mcp-adapters" }, @@ -1376,7 +1378,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "anthropic", specifier = ">=0.40.0" }, + { name = "docstring-parser", specifier = ">=0.17.0" }, { name = "fastapi", specifier = ">=0.115.5,<1.0.0" }, + { name = "jsonschema", specifier = ">=4.25.1" }, { name = "langchain", specifier = "==1.1.0" }, { name = "langchain-anthropic", specifier = ">=0.3.3" }, { name = "langchain-mcp-adapters", specifier = ">=0.1.0" }, diff --git a/web/package.json b/web/package.json index 197b558..cb78157 100644 --- a/web/package.json +++ b/web/package.json @@ -6,9 +6,9 @@ "dev": "concurrently \"npm run dev:ui\" \"npm run dev:agent\" --names ui,agent --prefix-colors blue,green --kill-others", "dev:debug": "LOG_LEVEL=debug npm run dev", "dev:agent": "cd agent && npx @langchain/langgraph-cli dev --port 8123 --no-browser", - "dev:ui": "next dev --turbopack", + "dev:ui": "next dev --turbopack -H 127.0.0.1", "build": "next build", - "start": "next start", + "start": "next start -H 127.0.0.1", "lint": "eslint .", "install:agent": "sh ./scripts/setup-agent.sh || scripts\\setup-agent.bat", "postinstall": "npm run install:agent" diff --git a/web/src/app/api/copilotkit/route.ts b/web/src/app/api/copilotkit/route.ts index a1f1757..8efa3a9 100644 --- a/web/src/app/api/copilotkit/route.ts +++ b/web/src/app/api/copilotkit/route.ts @@ -17,10 +17,12 @@ export const POST = async (req: NextRequest) => { // Get Auth0 session const session = await auth0.getSession(); - // Extract access token and roles from session - const accessToken = session?.accessToken; + // Extract roles from session const userRoles = session?.user?.roles || []; + console.log("[copilotkit] session exists:", !!session); + console.log("[copilotkit] userRoles:", userRoles); + // 2. Create the CopilotRuntime instance with Auth0 configuration const runtime = new CopilotRuntime({ agents: { @@ -28,9 +30,8 @@ export const POST = async (req: NextRequest) => { deploymentUrl: process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8123", graphId: "sample_agent", langsmithApiKey: process.env.LANGSMITH_API_KEY || "", - langgraphConfig: { - configurable: { - auth0_access_token: accessToken, + assistantConfig: { + context: { auth0_user_roles: userRoles, } } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 57dd894..6afaaa8 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -95,7 +95,7 @@ export default function CopilotKitPage() {