Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ updates:
- package-ecosystem: "pip"
directories:
- "/a2a/python-agent"
- "/mcp/credential-broker"
- "/mcp/remote-server-py"
- "/native/python"
- "/native/python-agent-aws"
- "/native/python-agent-github"
- "/native/python-agent-multi"
- "/web/django-allauth"
- "/web/fastapi-authlib"
- "/web/flask-authlib"
Expand All @@ -41,10 +45,14 @@ updates:
- package-ecosystem: "docker"
directories:
- "/a2a/python-agent"
- "/mcp/credential-broker"
- "/mcp/remote-server-py"
- "/mcp/remote-server-ts"
- "/native/node"
- "/native/python"
- "/native/python-agent-aws"
- "/native/python-agent-github"
- "/native/python-agent-multi"
- "/native/rust"
- "/spa/angular"
- "/spa/bff-express"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ jobs:
matrix:
include:
- directory: a2a/python-agent
- directory: mcp/credential-broker
- directory: mcp/remote-server-py
- directory: mcp/remote-server-ts
- directory: native/node
- directory: native/python
- directory: native/python-agent-aws
- directory: native/python-agent-github
- directory: native/python-agent-multi
- directory: native/rust
- directory: spa/angular
- directory: spa/bff-express
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ Terminal tools and headless servers using the Device Authorization Grant (RFC 86
| Framework | Directory | Language |
|-----------|-----------|----------|
| Python + requests | [`native/python`](native/python) | Python |
| Python Agent: AWS | [`native/python-agent-aws`](native/python-agent-aws) | Python |
| Python Agent: GitHub | [`native/python-agent-github`](native/python-agent-github) | Python |
| Python Agent: Multi-Credential | [`native/python-agent-multi`](native/python-agent-multi) | Python |
| Node.js + fetch | [`native/node`](native/node) | Node.js |
| Rust + reqwest | [`native/rust`](native/rust) | Rust |

Expand All @@ -59,6 +62,7 @@ Secure AI agent communication using Vouch for hardware-backed authentication.
|----------|-----------|-------------|
| MCP Remote Server (TypeScript) | [`mcp/remote-server-ts`](mcp/remote-server-ts) | [Model Context Protocol](https://modelcontextprotocol.io/) server with Bearer auth + Protected Resource Metadata ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) |
| MCP Remote Server (Python) | [`mcp/remote-server-py`](mcp/remote-server-py) | Same as above, in Python with FastMCP |
| MCP Credential Broker (Python) | [`mcp/credential-broker`](mcp/credential-broker) | MCP server that brokers AWS, GitHub, and SSH credentials on behalf of the authenticated user |
| A2A Agent (Python) | [`a2a/python-agent`](a2a/python-agent) | [Agent-to-Agent](https://github.com/a2aproject/A2A) agent with OpenID Connect security scheme in the Agent Card |

## Quick Start
Expand Down Expand Up @@ -116,6 +120,9 @@ Several examples go beyond basic login to demonstrate real-world OIDC patterns:
| Post-auth API calls | [`native/node`](native/node), [`native/python`](native/python) |
| Token expiry display | [`spa/react`](spa/react) |
| Profile claims display | [`spa/react`](spa/react) |
| Credential brokering (AWS) | [`native/python-agent-aws`](native/python-agent-aws), [`native/python-agent-multi`](native/python-agent-multi), [`mcp/credential-broker`](mcp/credential-broker) |
| Credential brokering (GitHub) | [`native/python-agent-github`](native/python-agent-github), [`native/python-agent-multi`](native/python-agent-multi), [`mcp/credential-broker`](mcp/credential-broker) |
| Credential brokering (SSH) | [`native/python-agent-multi`](native/python-agent-multi), [`mcp/credential-broker`](mcp/credential-broker) |

## Custom Claims

Expand Down
11 changes: 11 additions & 0 deletions mcp/credential-broker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM python:3.14-slim-trixie

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 3000
CMD ["python", "server.py"]
43 changes: 43 additions & 0 deletions mcp/credential-broker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# MCP Credential Broker + Vouch (Python)

A remote [Model Context Protocol](https://modelcontextprotocol.io/) server that brokers downstream credentials on behalf of the authenticated user.

This example demonstrates:
- **Credential brokering** -- exchanges a Vouch OIDC token for AWS, GitHub, and SSH credentials
- **Streamable HTTP transport** -- the MCP standard for remote servers
- **Protected Resource Metadata** ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) -- advertises Vouch as the authorization server
- **Bearer token validation** -- verifies JWTs issued by Vouch using ES256

## Tools

| Tool | Description |
|------|-------------|
| `get-aws-credentials` | Get an AWS ID token from Vouch, then exchange it for temporary AWS credentials via STS |
| `get-github-token` | Get a GitHub installation token scoped to the user's identity via Vouch |
| `get-ssh-certificate` | Sign an SSH public key with a Vouch-issued certificate |

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `VOUCH_ISSUER` | No | Vouch issuer URL (default: `https://us.vouch.sh`) |

## Run

```bash
docker build -t vouch-mcp-credential-broker .
docker run -p 3000:3000 \
-e VOUCH_ISSUER=https://us.vouch.sh \
vouch-mcp-credential-broker
```

## Endpoints

| Path | Description |
|------|-------------|
| `GET /.well-known/oauth-protected-resource` | Protected Resource Metadata (RFC 9728) |
| `POST /mcp` | MCP Streamable HTTP endpoint (requires Bearer token) |

## Production Considerations

The `get-aws-credentials` tool returns `SecretAccessKey` and `SessionToken` as plaintext in the MCP tool response. This is fine for demonstration purposes since the credentials are short-lived (1 hour max), but in production you should consider whether credentials flowing through MCP tool responses as plaintext matches your threat model. Alternatives include having the MCP server make AWS API calls directly on behalf of the user rather than returning raw credentials to the client.
6 changes: 6 additions & 0 deletions mcp/credential-broker/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
mcp>=1.12.0
uvicorn>=0.40
PyJWT>=2.11
cryptography>=46.0
pydantic>=2.0
httpx>=0.28
202 changes: 202 additions & 0 deletions mcp/credential-broker/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import os
import json
import contextvars
import xml.etree.ElementTree as ET
import jwt
import httpx
from jwt import PyJWKClient
from pydantic import AnyHttpUrl
from mcp.server.fastmcp import FastMCP
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings

VOUCH_ISSUER = os.environ.get('VOUCH_ISSUER', 'https://us.vouch.sh')
PORT = int(os.environ.get('PORT', '3000'))

# JWKS client for token verification
jwks_client = PyJWKClient(f'{VOUCH_ISSUER}/oauth/jwks')

# Store authenticated claims and raw token per-request
_current_claims = contextvars.ContextVar('current_claims', default=None)
_current_token = contextvars.ContextVar('current_token', default=None)

AWS_STS_NS = '{https://sts.amazonaws.com/doc/2011-06-15/}'


class VouchTokenVerifier(TokenVerifier):
"""Verify Vouch OIDC JWT tokens using JWKS."""

async def verify_token(self, token: str) -> AccessToken | None:
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=['ES256'],
issuer=VOUCH_ISSUER,
options={'verify_aud': False},
)
_current_claims.set(payload)
_current_token.set(token)
return AccessToken(
token=token,
client_id=payload.get('sub'),
scopes=(
payload.get('scope', '').split()
if isinstance(payload.get('scope'), str)
else []
),
)
except Exception:
return None


# Create MCP server with built-in auth and RFC 9728 metadata
mcp = FastMCP(
'vouch-credential-broker',
host='0.0.0.0',
port=PORT,
json_response=True,
token_verifier=VouchTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl(VOUCH_ISSUER),
resource_server_url=AnyHttpUrl(f'http://localhost:{PORT}'),
required_scopes=[],
),
)


@mcp.tool(name='get-aws-credentials')
async def get_aws_credentials(role_arn: str) -> str:
"""Exchange the user's Vouch session for temporary AWS credentials.

First obtains an AWS-specific ID token from Vouch, then exchanges it
with AWS STS AssumeRoleWithWebIdentity for temporary credentials."""
token = _current_token.get()
if not token:
return json.dumps({'error': 'No authentication context'}, indent=2)

async with httpx.AsyncClient() as client:
# Step 1: Get AWS-specific ID token from Vouch
vouch_resp = await client.get(
f'{VOUCH_ISSUER}/v1/credentials/aws/token',
headers={'Authorization': f'Bearer {token}'},
)

if vouch_resp.status_code != 200:
return json.dumps({
'error': 'Vouch AWS token request failed',
'status': vouch_resp.status_code,
'body': vouch_resp.text,
}, indent=2)

aws_id_token = vouch_resp.json()['id_token']

async with httpx.AsyncClient() as client:
# Step 2: Exchange ID token for AWS credentials via STS
sts_resp = await client.post(
'https://sts.amazonaws.com/',
data={
'Action': 'AssumeRoleWithWebIdentity',
'RoleArn': role_arn,
'RoleSessionName': 'vouch-mcp',
'WebIdentityToken': aws_id_token,
'Version': '2011-06-15',
},
)

if sts_resp.status_code != 200:
return json.dumps({
'error': 'AWS STS request failed',
'status': sts_resp.status_code,
'body': sts_resp.text,
}, indent=2)

root = ET.fromstring(sts_resp.text)
creds = root.find(
f'{AWS_STS_NS}AssumeRoleWithWebIdentityResult'
f'/{AWS_STS_NS}Credentials'
)
if creds is None:
return json.dumps({
'error': 'Failed to parse STS response',
'body': sts_resp.text,
}, indent=2)

return json.dumps({
'AccessKeyId': creds.findtext(f'{AWS_STS_NS}AccessKeyId'),
'SecretAccessKey': creds.findtext(
f'{AWS_STS_NS}SecretAccessKey'
),
'SessionToken': creds.findtext(f'{AWS_STS_NS}SessionToken'),
'Expiration': creds.findtext(f'{AWS_STS_NS}Expiration'),
}, indent=2)


@mcp.tool(name='get-github-token')
async def get_github_token(
owner: str = '',
repositories: list[str] | None = None,
) -> str:
"""Get a GitHub installation token scoped to the user's identity
via Vouch."""
token = _current_token.get()
if not token:
return json.dumps({'error': 'No authentication context'}, indent=2)

body = {}
if owner:
body['owner'] = owner
if repositories:
body['repositories'] = repositories

async with httpx.AsyncClient() as client:
resp = await client.post(
f'{VOUCH_ISSUER}/v1/credentials/github/token',
headers={'Authorization': f'Bearer {token}'},
json=body,
)

if resp.status_code != 200:
return json.dumps({
'error': 'GitHub token request failed',
'status': resp.status_code,
'body': resp.text,
}, indent=2)

return json.dumps(resp.json(), indent=2)


@mcp.tool(name='get-ssh-certificate')
async def get_ssh_certificate(public_key: str) -> str:
"""Sign an SSH public key with a Vouch-issued certificate for the
authenticated user."""
token = _current_token.get()
if not token:
return json.dumps({'error': 'No authentication context'}, indent=2)

async with httpx.AsyncClient() as client:
resp = await client.post(
f'{VOUCH_ISSUER}/v1/credentials/ssh',
headers={'Authorization': f'Bearer {token}'},
json={'public_key': public_key},
)

if resp.status_code != 200:
return json.dumps({
'error': 'SSH certificate request failed',
'status': resp.status_code,
'body': resp.text,
}, indent=2)

return json.dumps(resp.json(), indent=2)


if __name__ == '__main__':
print(f'MCP credential broker running on http://localhost:{PORT}')
print(
'Protected Resource Metadata: '
f'http://localhost:{PORT}/.well-known/oauth-protected-resource'
)
print(f'MCP endpoint: http://localhost:{PORT}/mcp')
mcp.run(transport='streamable-http')
11 changes: 11 additions & 0 deletions native/python-agent-aws/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
FROM python:3.14-slim-trixie

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

ENV PYTHONUNBUFFERED=1
CMD ["python", "agent.py"]
Loading