Skip to content

Commit cb52616

Browse files
committed
Merge branch 'feature'
2 parents f8d8da9 + c4fc052 commit cb52616

12 files changed

Lines changed: 1220 additions & 42 deletions

File tree

.env.example

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
OBP_BASE_URL="http://localhost:8080"
33
OBP_API_VERSION="v6.0.0"
44

5+
# Basic Server Config
6+
FASTMCP_PORT=9101
7+
FASTMCP_HOST=0.0.0.0
8+
59
# OAuth Settings
610
ENABLE_OAUTH="false"
711

812
## this is the url of this mcp application i.e. http://localhost:9100
9-
BASE_URL="http://localhost:9100"
13+
BASE_URL="http://localhost:9101"
1014

1115
# Choose authentication provider:
1216
# - "bearer-only": Simple JWT validation only (recommended for Opey/internal agents)
@@ -28,6 +32,18 @@ AUTH_PROVIDER=obp-oidc
2832
# For bearer-only: Set this to accept tokens from OBP-OIDC
2933
OBP_OIDC_ISSUER_URL=http://localhost:9000/obp-oidc
3034

35+
# This setting controls how we authorize API requests to OBP after authentication.
36+
# Options:
37+
# - "oauth": Use OAuth the oauth access token. Best for when using an MCP client that cannot perform the
38+
# consent flow itself (e.g. VS Code extension, Claude Desktop).
39+
# - "consent": Elicit user consent before each API call. Best for MCP clients like Opey that will support
40+
# user consent flows.
41+
# - "none": No authorization method is used. OBP-API calls are made but with limited access (only public endpoints).
42+
OBP_AUTHORIZATION_VIA="none" # Options: "oauth", "consent", "none"
43+
44+
# OBP API consumer key that opey uses (required for "consent" authorization method with opey)
45+
OPEY_OPEY_OBP_CONSUMER_KEY="obp-opey-ii-client"
46+
3147
# Multi-issuer bearer-only mode:
3248
# Set BOTH KEYCLOAK_REALM_URL and OBP_OIDC_ISSUER_URL with AUTH_PROVIDER=bearer-only
3349
# to accept tokens from either identity provider. The server will route validation
@@ -54,3 +70,6 @@ UPDATE_INDEX_ENDPOINT_TYPE="all"
5470

5571
# Periodic refresh interval in minutes (0 to disable)
5672
REFRESH_INTERVAL_MINUTES="5"
73+
74+
# Options: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
75+
LOG_LEVEL="INFO"

CLAUDE.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
OBP-MCP is a Model Context Protocol (MCP) server for the Open Bank Project API, built with `fastmcp`. It exposes 600+ OBP API endpoints to AI assistants through a hybrid tag-based routing system with OAuth 2.1 authentication.
8+
9+
## Common Commands
10+
11+
All commands use `uv` as the package manager:
12+
13+
```bash
14+
# Install/sync dependencies
15+
uv sync
16+
17+
# Run the server
18+
./run_server.sh # normal mode
19+
./run_server.sh --watch # auto-reload on file changes
20+
uv run python src/mcp_server_obp/server.py # direct
21+
22+
# Run tests
23+
uv run pytest # all tests
24+
uv run pytest tests/test_auth.py # single file
25+
uv run pytest -v # verbose
26+
27+
# Generate data indexes (requires OBP_BASE_URL in .env)
28+
uv run python scripts/generate_endpoint_index.py
29+
uv run python scripts/generate_glossary_index.py
30+
31+
# Add a dependency
32+
uv add <package>
33+
```
34+
35+
## Architecture
36+
37+
### Three-Step Endpoint Discovery (not RAG)
38+
39+
The server avoids vector databases. Instead it uses a tag-based discovery pattern:
40+
41+
1. **`list_endpoints_by_tag(tags)`** — returns lightweight summaries (~50-100 tokens each) from `endpoint_index.json` (250KB)
42+
2. **`get_endpoint_schema(endpoint_id)`** — lazy-loads full OpenAPI schema from `endpoint_schemas.json` (4.3MB)
43+
3. **`call_obp_api(endpoint_id, ...)`** — executes the actual HTTP request against OBP-API
44+
45+
Additional tools: `list_glossary_terms(search_query)` and `get_glossary_term(term_id)` for 800+ banking terms.
46+
47+
### Key Modules
48+
49+
- **`src/mcp_server_obp/server.py`** — FastMCP server definition, all tool/resource registrations
50+
- **`src/mcp_server_obp/auth.py`** — Multi-provider auth: `bearer-only`, `obp-oidc`, `keycloak`, `none`
51+
- **`src/mcp_server_obp/lifespan.py`** — Server lifecycle, startup index updates, periodic refresh
52+
- **`src/tools/endpoint_index.py`**`EndpointIndex` class with strict Pydantic types, tag filtering, lazy schema loading
53+
- **`src/tools/glossary_index.py`**`GlossaryIndex` class for term search/retrieval
54+
- **`database/`** — JSON index files, `startup_updater.py` for auto-refresh, `data_hash_manager.py` for change detection
55+
- **`scripts/`** — Standalone generators that fetch from OBP resource-docs/swagger API
56+
57+
### Authentication Modes (`AUTH_PROVIDER` env var)
58+
59+
| Mode | Use Case | Notes |
60+
|------|----------|-------|
61+
| `bearer-only` | Internal agents (e.g., Opey) | JWT validation only, multi-issuer support |
62+
| `obp-oidc` | External MCP clients | Full OAuth 2.1 + Dynamic Client Registration |
63+
| `keycloak` | External MCP clients | OAuth 2.1 + minimal DCR proxy workaround |
64+
| `none` | Development/testing | No auth required |
65+
66+
### Data Flow
67+
68+
- JSON indexes stored in `database/` — no external database needed
69+
- `DataHashManager` compares hashes to detect upstream changes
70+
- `IndexStartupUpdater` refreshes on startup if `UPDATE_INDEX_ON_STARTUP=true`
71+
- Periodic refresh configurable via `REFRESH_INTERVAL_MINUTES`
72+
73+
## Configuration
74+
75+
All config via environment variables. Copy `.env.example` to `.env`. Key variables:
76+
77+
- `OBP_BASE_URL` — OBP API instance URL
78+
- `OBP_API_VERSION` — API version (e.g., `v6.0.0`)
79+
- `AUTH_PROVIDER` — Authentication mode (see table above)
80+
- `OBP_AUTHORIZATION_VIA` — How API calls are authorized: `oauth`, `consent`, or `none`
81+
- `LOG_LEVEL``DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`
82+
- `FASTMCP_HOST` / `FASTMCP_PORT` — Server bind address
83+
84+
## Testing Conventions
85+
86+
- Tests live in `tests/` using pytest
87+
- `conftest.py` provides shared fixtures (auth URLs, JWT payloads, `clean_env` for env isolation)
88+
- Test files: `test_auth.py` (auth providers), `test_endpoint_index.py` (endpoint index)
89+
- Tests use `pytest-asyncio` for async code

ai_env

Lines changed: 0 additions & 7 deletions
This file was deleted.

database/obp_utils.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,47 @@
66
from typing import Dict, Any
77

88

9-
def get_obp_config(endpoint_type="static"):
10-
"""Get OBP configuration from environment variables."""
9+
def get_obp_config(endpoint_type: str = "static", use_resource_docs: bool = True) -> Dict[str, str]:
10+
"""
11+
Get OBP configuration from environment variables.
12+
13+
Args:
14+
endpoint_type: Type of endpoints to fetch ("static", "dynamic", or "all")
15+
use_resource_docs: If True, use resource-docs endpoint (includes roles).
16+
If False, use swagger endpoint.
17+
18+
Returns:
19+
Dictionary with configuration including URLs for data fetching
20+
"""
1121
base_url = os.getenv("OBP_BASE_URL")
1222
api_version = os.getenv("OBP_API_VERSION")
1323

1424
if not all([base_url, api_version]):
1525
raise ValueError("Missing required environment variables: OBP_BASE_URL, OBP_API_VERSION")
1626

17-
return {
27+
config = {
1828
"base_url": base_url,
1929
"api_version": api_version,
2030
"glossary_url": f"{base_url}/obp/{api_version}/api/glossary",
21-
"swagger_url": f"{base_url}/obp/{api_version}/resource-docs/{api_version}/swagger?content={endpoint_type}"
31+
"swagger_url": f"{base_url}/obp/{api_version}/resource-docs/{api_version}/swagger?content={endpoint_type}",
32+
"resource_docs_url": f"{base_url}/obp/{api_version}/resource-docs/{api_version}/obp?content={endpoint_type}",
2233
}
34+
35+
# Set the primary data URL based on preference
36+
if use_resource_docs:
37+
config["data_url"] = config["resource_docs_url"]
38+
config["data_format"] = "resource_docs"
39+
else:
40+
config["data_url"] = config["swagger_url"]
41+
config["data_format"] = "swagger"
42+
43+
return config
2344

2445

2546
def fetch_obp_data(url: str) -> Dict[str, Any]:
2647
"""Fetch data from OBP API endpoint."""
2748
print(f"Fetching data from: {url}")
28-
response = requests.get(url, timeout=30)
49+
response = requests.get(url, timeout=60) # Increased timeout for large responses
2950
try:
3051
response.raise_for_status()
3152
except requests.exceptions.HTTPError as e:

scripts/generate_endpoint_index.py

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
#!/usr/bin/env python3
22
"""
3-
Script to generate the lightweight endpoint index from OBP swagger data.
3+
Script to generate the lightweight endpoint index from OBP resource docs.
44
5-
This script fetches the swagger/OpenAPI specification from the OBP API
6-
and generates a lightweight index for fast endpoint discovery.
5+
This script fetches the resource docs from the OBP API and generates a
6+
lightweight index for fast endpoint discovery. Resource docs provide richer
7+
information than swagger, including roles/entitlements required for each endpoint.
78
"""
89
import os
910
import sys
@@ -23,7 +24,7 @@ def main():
2324

2425
# Parse command line arguments
2526
parser = argparse.ArgumentParser(
26-
description="Generate lightweight endpoint index from OBP swagger data."
27+
description="Generate lightweight endpoint index from OBP resource docs."
2728
)
2829
parser.add_argument(
2930
"--endpoints",
@@ -37,33 +38,48 @@ def main():
3738
default=None,
3839
help="Output file path for the index (default: database/endpoint_index.json)"
3940
)
41+
parser.add_argument(
42+
"--use-swagger",
43+
action="store_true",
44+
help="Use swagger endpoint instead of resource docs (not recommended - lacks role info)"
45+
)
4046
args = parser.parse_args()
4147

48+
use_resource_docs = not args.use_swagger
49+
4250
try:
4351
# Get configuration
44-
config = get_obp_config(args.endpoints)
52+
config = get_obp_config(args.endpoints, use_resource_docs=use_resource_docs)
4553

46-
# Fetch swagger data
54+
# Fetch data
4755
print("\n" + "="*50)
48-
print("FETCHING SWAGGER DATA")
56+
print(f"FETCHING {'RESOURCE DOCS' if use_resource_docs else 'SWAGGER'} DATA")
4957
print("="*50)
50-
swagger_data = fetch_obp_data(config["swagger_url"])
58+
print(f"URL: {config['data_url']}")
59+
60+
data = fetch_obp_data(config["data_url"])
5161

5262
# Build index
5363
print("\n" + "="*50)
5464
print("BUILDING ENDPOINT INDEX")
5565
print("="*50)
5666

5767
index = EndpointIndex(index_file=args.output)
58-
index.build_index_from_swagger(swagger_data)
68+
69+
if use_resource_docs:
70+
index.build_index_from_resource_docs(data)
71+
else:
72+
index.build_index_from_swagger(data)
5973

6074
# Print statistics
6175
print("\n" + "="*50)
6276
print("INDEX GENERATION COMPLETE")
6377
print("="*50)
78+
print(f"Data source: {'Resource Docs' if use_resource_docs else 'Swagger'}")
6479
print(f"Total endpoints indexed: {len(index._index)}")
6580
print(f"Total unique tags: {len(index.get_all_tags())}")
66-
print(f"Output file: {index.index_file}")
81+
print(f"Index file: {index.index_file}")
82+
print(f"Schemas file: {index.schemas_file}")
6783

6884
# Print sample of tags
6985
all_tags = index.get_all_tags()
@@ -72,8 +88,18 @@ def main():
7288
for tag in all_tags[:10]:
7389
print(f" - {tag}")
7490

91+
# If using resource docs, show role statistics
92+
if use_resource_docs:
93+
endpoints_with_roles = sum(
94+
1 for schema in index._schemas.values()
95+
if schema.get("roles")
96+
)
97+
print(f"\nEndpoints with role requirements: {endpoints_with_roles}")
98+
7599
except Exception as e:
76100
print(f"Error during index generation: {e}")
101+
import traceback
102+
traceback.print_exc()
77103
sys.exit(1)
78104

79105

src/mcp_server_obp/auth.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,14 @@ async def verify_token(self, token: str):
178178
AccessToken object if valid, None if invalid or expired
179179
"""
180180
logger.info("🔐 Starting token verification")
181-
logger.debug(f"Token (first 20 chars): {token[:20]}...")
182-
logger.debug(f"Configured issuers: {list(self._verifiers.keys())}")
181+
logger.info(f"Token (first 20 chars): {token[:20]}...")
182+
logger.info(f"Configured issuers: {list(self._verifiers.keys())}")
183+
184+
# Fast-fail on clearly malformed tokens to avoid noisy verifier errors
185+
token = token.strip()
186+
if token.count(".") < 2:
187+
logger.warning("Rejected token without JWT structure (expected header.payload.signature)")
188+
return None
183189

184190
# Extract issuer from token
185191
issuer = self._extract_issuer(token)

src/mcp_server_obp/lifespan.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import json
23
import logging
34
import os
45
from contextlib import asynccontextmanager
@@ -8,6 +9,53 @@
89
logger = logging.getLogger(__name__)
910

1011

12+
def print_client_configs():
13+
"""Print copy-pasteable MCP client configuration snippets."""
14+
host = os.getenv("FASTMCP_HOST", "127.0.0.1")
15+
port = os.getenv("FASTMCP_PORT", "9100")
16+
auth_provider = os.getenv("AUTH_PROVIDER", "none")
17+
requires_auth = auth_provider != "none"
18+
base_url = f"http://{host}:{port}/mcp"
19+
20+
configs = {
21+
"Opey": {
22+
"servers": [
23+
{
24+
"name": "obp",
25+
"url": base_url,
26+
"transport": "http",
27+
"requires_auth": requires_auth,
28+
}
29+
]
30+
},
31+
"Claude Desktop / claude_desktop_config.json": {
32+
"mcpServers": {
33+
"obp": {
34+
"url": base_url,
35+
}
36+
}
37+
},
38+
"VS Code / settings.json": {
39+
"mcp": {
40+
"servers": {
41+
"obp": {
42+
"url": base_url,
43+
}
44+
}
45+
}
46+
},
47+
}
48+
49+
separator = "=" * 60
50+
print(f"\n{separator}")
51+
print("MCP Client Configurations")
52+
print(separator)
53+
for name, config in configs.items():
54+
print(f"\n--- {name} ---\n")
55+
print(json.dumps(config, indent=2))
56+
print(f"\n{separator}\n")
57+
58+
1159
async def periodic_index_refresh(interval_minutes: int):
1260
"""Periodically check and update the index every N minutes."""
1361
interval_seconds = interval_minutes * 60
@@ -42,7 +90,8 @@ async def lifespan(server) -> AsyncIterator[dict[str, Any]]:
4290
"""
4391
# Startup actions
4492
logger.info("Starting MCP server...")
45-
93+
print_client_configs()
94+
4695
# Check and update index on startup
4796
success = await update_index_on_startup()
4897
if not success:

0 commit comments

Comments
 (0)