diff --git a/README.md b/README.md index e144942e..d7e3aea1 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ if __name__ == "__main__": python server.py ``` -For more detail refer to the [mcp](https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#streamable-http-transport) documentation +For more details, refer to the [mcp](https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#streamable-http-transport) documentation. ### Configure the remote MCP in your AI client, like [Cursor](https://cursor.com/?from=home) @@ -53,17 +53,17 @@ For more detail refer to the [mcp](https://github.com/modelcontextprotocol/pytho } ``` -### Test the remote server with client, +### Test the remote server with client Cursor Hello World Agent Call ### Signup to Keycard and get your zone identifier -Refer to [docs](https://docs.keycard.ai/) on how to signup. Navigate to Zone Settings to obtain the zone id +Refer to [docs](https://docs.keycard.ai/) on how to sign up. Navigate to Zone Settings to obtain the zone ID. Keycard ZoneId Information -### Configure Your prefered Identity Provider +### Configure Your Preferred Identity Provider Keycard Identity Provider Configuration @@ -92,17 +92,17 @@ mcp = FastMCP("Minimal MCP") def hello_world(name: str) -> str: return f"Hello, {name}!" -# Create starlett app to handle authorization flows +# Create Starlette app to handle authorization flows app = access.app(mcp) ``` ### Run Your Server -The authorization flows require additonal handlers to advertise the metadata. +The authorization flows require additional handlers to advertise the metadata. -This is implemented using underlying starlett application, for more information refer to official [mcp](https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#streamablehttp-servers) documentation +This is implemented using the underlying Starlette application. For more information, refer to the official [mcp](https://github.com/modelcontextprotocol/python-sdk?tab=readme-ov-file#streamablehttp-servers) documentation. -You can use any async server, for example [uvicorn](https://www.uvicorn.org/) +You can use any async server, for example [uvicorn](https://www.uvicorn.org/): ```bash uv add uvicorn @@ -115,7 +115,7 @@ pip install uvicorn ``` ```bash -uvicorn server:app +python -m uvicorn server:app ``` ### Authenticate in client @@ -125,6 +125,79 @@ uvicorn server:app ### 🎉 Your MCP server is now running with KeyCard authentication! 🎉 +## Features + +### Delegated Access + +You can use Keycard to allow MCP servers to access other resources on behalf of the user. + +It automatically requests user consent and performs necessary secure exchanges to provide granular access to resources. + +#### Configure credential provider + +Configure a credential provider for your resource, for example Google Workspace. + +Keycard Credential Provider Configuration + +#### Configure protected resource + +Configure a protected resource, for example the Google Drive API. + +Keycard Resource Creation + +#### Allow access from MCP to protected resource + +To allow the MCP server to make delegated calls to the API, set the dependency on the MCP server for the protected resource. + +Keycard Set Dependency + +#### Give the MCP server identity secret + +In order for the MCP server to securely perform exchanges, it requires an identity secret. + +Keycard Resource Identity + +Note: Keep the client_id and client_secret safe. We will use them in the next steps. + +#### Add delegation control to tool calls + +Note: For demonstration, we will print a different message when access is granted. +In real use cases, you would use the token to make requests to downstream APIs. + +```python +from mcp.server.fastmcp import FastMCP, Context + +from keycardai.mcp.server.auth import AuthProvider, AccessContext, BasicAuth + +# From the zone setting above +zone_id = "90zqtq5lvtobrmyl3b0i0k2z1q" + +access = AuthProvider( + zone_id = zone_id, + mcp_server_name="Hello World Mcp", + auth=BasicAuth(os.getenv("KEYCARD_CLIENT_ID"), os.getenv("KEYCARD_CLIENT_SECRET"))) +) + +mcp = FastMCP("Minimal MCP") + +protected_resource_identifier = "https://protected-api" + +@mcp.tool() +@access.grant(protected_resource_identifier) +def hello_world(ctx: Context, access_context: AccessContext, name: str) -> str: + msg = f"Hello, {name}!" + if access_context.access(protected_resource_identifier).access_token: + msg = f"Hello, {name}! I can see you have extra access" + return msg + +# Create Starlette app to handle authorization flows +app = access.app(mcp) +``` + +#### Use obtained access to make API calls on behalf of users + +Keycard Set Dependency + ## Overview diff --git a/docs/images/cursor_delegated_access_example.png b/docs/images/cursor_delegated_access_example.png new file mode 100644 index 00000000..d65111e3 Binary files /dev/null and b/docs/images/cursor_delegated_access_example.png differ diff --git a/docs/images/keycard_credential_provider_config.png b/docs/images/keycard_credential_provider_config.png new file mode 100644 index 00000000..598f51b7 Binary files /dev/null and b/docs/images/keycard_credential_provider_config.png differ diff --git a/docs/images/keycard_identity_configuration.png b/docs/images/keycard_identity_configuration.png new file mode 100644 index 00000000..60445eb2 Binary files /dev/null and b/docs/images/keycard_identity_configuration.png differ diff --git a/docs/images/keycard_resource_create.png b/docs/images/keycard_resource_create.png new file mode 100644 index 00000000..41b107be Binary files /dev/null and b/docs/images/keycard_resource_create.png differ diff --git a/docs/images/keycard_set_dependency.png b/docs/images/keycard_set_dependency.png new file mode 100644 index 00000000..3ff07c03 Binary files /dev/null and b/docs/images/keycard_set_dependency.png differ diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ae12fc14..cb641dbe 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -22,7 +22,7 @@ pip install keycardai-mcp 1. Sign up at [keycard.ai](https://keycard.ai) 2. Navigate to Zone Settings to get your zone ID -3. Configure your preferred identity provider +3. Configure your preferred identity provider (Google, Microsoft, etc.) 4. Create an MCP resource in your zone ### Add Authentication to Your MCP Server @@ -65,13 +65,54 @@ uvicorn server:app - ✅ **Token Exchange**: Automatic delegated token exchange for accessing external APIs - ✅ **Production Ready**: Battle-tested security patterns and error handling +### Delegated Access + +KeyCard allows MCP servers to access other resources on behalf of users with automatic consent and secure token exchange. + +#### Setup Protected Resources + +1. **Configure credential provider** (e.g., Google Workspace) +2. **Configure protected resource** (e.g., Google Drive API) +3. **Set MCP server dependencies** to allow delegated access +4. **Create client secret identity** to provide authentication method + +#### Add Delegation to Your Tools + +```python +from mcp.server.fastmcp import FastMCP, Context +from keycardai.mcp.server.auth import AuthProvider, AccessContext, BasicAuth +import os + +# Configure your provider +access = AuthProvider( + zone_id="your_zone_id", + mcp_server_name="My MCP Server", + auth=BasicAuth( + os.getenv("KEYCARD_CLIENT_ID"), + os.getenv("KEYCARD_CLIENT_SECRET") + ) +) + +mcp = FastMCP("My MCP Server") + +@mcp.tool() +@access.grant("https://protected-api") +def protected_tool(ctx: Context, access_context: AccessContext, name: str) -> str: + # Use the access_context to call external APIs on behalf of the user + token = access_context.access("https://protected-api").access_token + # Make authenticated API calls... + return f"Protected data for {name}" + +app = access.app(mcp) +``` + ## Examples For complete examples and advanced usage patterns, see our [documentation](https://docs.keycard.ai). ## License -MIT License - see [LICENSE](../../LICENSE) file for details. +MIT License - see [LICENSE](https://github.com/keycardai/python-sdk/blob/main/LICENSE) file for details. ## Support diff --git a/packages/mcp/src/keycardai/mcp/server/auth/provider.py b/packages/mcp/src/keycardai/mcp/server/auth/provider.py index 2f39ed5a..cec4bb7f 100644 --- a/packages/mcp/src/keycardai/mcp/server/auth/provider.py +++ b/packages/mcp/src/keycardai/mcp/server/auth/provider.py @@ -312,8 +312,18 @@ def grant(self, resources: str | list[str]): Usage: ```python + from mcp.server.fastmcp import Context + # Async function @provider.grant("https://api.example.com") - async def my_tool(ctx: AccessContext, user_id: str): + async def my_async_tool(ctx: AccessContext, request_ctx: Context, user_id: str): + token = ctx.access("https://api.example.com").access_token + # Use token to call the external API + headers = {"Authorization": f"Bearer {token}"} + # ... make API call + + # Sync function (also supported) + @provider.grant("https://api.example.com") + def my_sync_tool(ctx: AccessContext, request_ctx: Context, user_id: str): token = ctx.access("https://api.example.com").access_token # Use token to call the external API headers = {"Authorization": f"Bearer {token}"} @@ -322,7 +332,11 @@ async def my_tool(ctx: AccessContext, user_id: str): The decorated function must: - Have a parameter annotated with `AccessContext` type (e.g., `my_ctx: AccessContext = None`) - - Be async (token exchange is async) + - Have a parameter annotated with `Context` type from FastMCP (e.g., `request_ctx: Context`) + - Can be either async or sync (the decorator handles both cases) + + Note: The `Context` parameter is required for accessing request authentication information. + Without it, the decorator cannot extract the user's authentication token. Error handling: - Returns structured error response if token exchange fails @@ -344,6 +358,9 @@ def decorator(func: Callable) -> Callable: new_sig = original_sig.replace(parameters=new_params) + # mcp.server.fastmcp always run in async mode + is_async_func = inspect.iscoroutinefunction(func) + @wraps(func) async def wrapper(*args, **kwargs) -> Any: try: @@ -407,7 +424,10 @@ async def wrapper(*args, **kwargs) -> Any: if access_ctx_param_name: kwargs[access_ctx_param_name] = access_ctx - return await func(*args, **kwargs) + if is_async_func: + return await func(*args, **kwargs) + else: + return func(*args, **kwargs) except Exception as e: return { diff --git a/uv.lock b/uv.lock index e64f7626..58bb6032 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" [manifest] @@ -7,6 +7,7 @@ members = [ "keycardai", "keycardai-mcp", "keycardai-mcp-fastmcp", + "keycardai-mcp-slack", "keycardai-oauth", ] @@ -869,6 +870,24 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.7.1" }, ] +[[package]] +name = "keycardai-mcp-slack" +source = { editable = "packages/mcp-slack" } +dependencies = [ + { name = "httpx" }, + { name = "keycardai-oauth" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.2" }, + { name = "keycardai-oauth", editable = "packages/oauth" }, + { name = "pydantic", specifier = ">=2.11.7" }, + { name = "pydantic-settings", specifier = ">=2.7.1" }, +] + [[package]] name = "keycardai-oauth" source = { editable = "packages/oauth" }