avanza-mcp 1.0.0__py3-none-any.whl

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.
avanza_mcp/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Avanza MCP Server - Public API Access via FastMCP.
2
+
3
+ This server provides read-only access to Avanza's public market data API.
4
+ No authentication required - all endpoints are publicly accessible.
5
+
6
+ Provides access to:
7
+ - Stock information, quotes, and charts
8
+ - Fund information, sustainability metrics, and performance
9
+ - Market data including order depth, trades, and broker activity
10
+ - Real-time market status and trading hours
11
+ """
12
+
13
+ __version__ = "1.0.0"
14
+
15
+ from fastmcp import FastMCP
16
+
17
+ # Create FastMCP instance
18
+ mcp = FastMCP("Avanza MCP Server")
19
+
20
+ # Import modules to register tools/resources/prompts via decorators
21
+ # The @mcp.tool/@mcp.resource/@mcp.prompt decorators handle registration
22
+ from . import prompts # noqa: F401, E402
23
+ from . import resources # noqa: F401, E402
24
+ from . import tools # noqa: F401, E402
25
+
26
+
27
+ def main() -> None:
28
+ """Entry point for the MCP server."""
29
+ mcp.run()
@@ -0,0 +1,25 @@
1
+ """Avanza API client module."""
2
+
3
+ from .base import AvanzaClient
4
+ from .endpoints import PublicEndpoint
5
+ from .exceptions import (
6
+ AvanzaAPIError,
7
+ AvanzaAuthError,
8
+ AvanzaError,
9
+ AvanzaNetworkError,
10
+ AvanzaNotFoundError,
11
+ AvanzaRateLimitError,
12
+ AvanzaTimeoutError,
13
+ )
14
+
15
+ __all__ = [
16
+ "AvanzaClient",
17
+ "PublicEndpoint",
18
+ "AvanzaError",
19
+ "AvanzaAPIError",
20
+ "AvanzaAuthError",
21
+ "AvanzaNetworkError",
22
+ "AvanzaNotFoundError",
23
+ "AvanzaRateLimitError",
24
+ "AvanzaTimeoutError",
25
+ ]
@@ -0,0 +1,375 @@
1
+ """Base HTTP client for Avanza API."""
2
+
3
+ import logging
4
+ import uuid
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from tenacity import (
9
+ retry,
10
+ retry_if_exception_type,
11
+ stop_after_attempt,
12
+ wait_exponential,
13
+ )
14
+
15
+ from .. import __version__
16
+ from .exceptions import (
17
+ AvanzaAPIError,
18
+ AvanzaAuthError,
19
+ AvanzaNetworkError,
20
+ AvanzaNotFoundError,
21
+ AvanzaRateLimitError,
22
+ AvanzaRetryableError,
23
+ AvanzaTimeoutError,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class AvanzaClient:
30
+ """Async HTTP client for Avanza public API."""
31
+
32
+ # Default configuration
33
+ DEFAULT_BASE_URL = "https://www.avanza.se"
34
+ DEFAULT_TIMEOUT = 30.0
35
+ DEFAULT_CONNECT_TIMEOUT = 5.0
36
+ DEFAULT_MAX_CONNECTIONS = 10
37
+ DEFAULT_MAX_KEEPALIVE = 5
38
+ DEFAULT_MAX_RETRIES = 3
39
+
40
+ def __init__(
41
+ self,
42
+ base_url: str = DEFAULT_BASE_URL,
43
+ timeout: float = DEFAULT_TIMEOUT,
44
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
45
+ max_connections: int = DEFAULT_MAX_CONNECTIONS,
46
+ max_keepalive_connections: int = DEFAULT_MAX_KEEPALIVE,
47
+ max_retries: int = DEFAULT_MAX_RETRIES,
48
+ ) -> None:
49
+ """Initialize Avanza client.
50
+
51
+ Args:
52
+ base_url: Base URL for Avanza API
53
+ timeout: Read timeout in seconds
54
+ connect_timeout: Connection timeout in seconds
55
+ max_connections: Maximum number of concurrent connections
56
+ max_keepalive_connections: Maximum number of keepalive connections
57
+ max_retries: Maximum number of retry attempts for transient failures
58
+ """
59
+ self._base_url = base_url
60
+ self._timeout = timeout
61
+ self._connect_timeout = connect_timeout
62
+ self._max_connections = max_connections
63
+ self._max_keepalive_connections = max_keepalive_connections
64
+ self._max_retries = max_retries
65
+ self._client: httpx.AsyncClient | None = None
66
+
67
+ async def __aenter__(self) -> "AvanzaClient":
68
+ """Initialize httpx client with connection pooling.
69
+
70
+ Returns:
71
+ Self for context manager usage
72
+ """
73
+ headers = self._build_headers()
74
+
75
+ # Configure timeouts with separate connect and read values
76
+ timeout = httpx.Timeout(
77
+ self._timeout,
78
+ connect=self._connect_timeout,
79
+ )
80
+
81
+ # Configure connection pooling limits
82
+ limits = httpx.Limits(
83
+ max_connections=self._max_connections,
84
+ max_keepalive_connections=self._max_keepalive_connections,
85
+ )
86
+
87
+ self._client = httpx.AsyncClient(
88
+ base_url=self._base_url,
89
+ headers=headers,
90
+ timeout=timeout,
91
+ limits=limits,
92
+ follow_redirects=True,
93
+ )
94
+
95
+ return self
96
+
97
+ async def __aexit__(
98
+ self,
99
+ exc_type: type[BaseException] | None,
100
+ exc_val: BaseException | None,
101
+ exc_tb: Any,
102
+ ) -> None:
103
+ """Clean up httpx client.
104
+
105
+ Args:
106
+ exc_type: Exception type if an error occurred
107
+ exc_val: Exception value if an error occurred
108
+ exc_tb: Exception traceback if an error occurred
109
+ """
110
+ if self._client:
111
+ await self._client.aclose()
112
+
113
+ def _build_headers(self) -> dict[str, str]:
114
+ """Build request headers.
115
+
116
+ Returns:
117
+ Dictionary of HTTP headers
118
+ """
119
+ return {
120
+ "User-Agent": f"avanza-mcp/{__version__}",
121
+ "Accept": "application/json",
122
+ }
123
+
124
+ def _generate_request_id(self) -> str:
125
+ """Generate a unique request ID for debugging.
126
+
127
+ Returns:
128
+ Short unique identifier string
129
+ """
130
+ return str(uuid.uuid4())[:8]
131
+
132
+ def _handle_error(
133
+ self,
134
+ response: httpx.Response,
135
+ path: str,
136
+ request_id: str,
137
+ params: dict[str, Any] | None = None,
138
+ ) -> None:
139
+ """Handle HTTP error responses with enhanced context.
140
+
141
+ Args:
142
+ response: HTTP response object
143
+ path: Request path for error context
144
+ request_id: Request ID for debugging
145
+ params: Query parameters for error context
146
+
147
+ Raises:
148
+ AvanzaNotFoundError: If resource not found (404)
149
+ AvanzaAuthError: If authentication failed (401, 403)
150
+ AvanzaRateLimitError: If rate limit exceeded (429)
151
+ AvanzaAPIError: For other API errors
152
+ """
153
+ status_code = response.status_code
154
+
155
+ # Try to extract error message from response
156
+ try:
157
+ error_data = response.json()
158
+ message = error_data.get("message", response.text)
159
+ except Exception:
160
+ message = response.text or f"HTTP {status_code}"
161
+
162
+ # Add request context to error message
163
+ context = f"[{request_id}] {path}"
164
+ if params:
165
+ context += f" params={params}"
166
+
167
+ logger.warning(
168
+ "API error: status=%d path=%s request_id=%s message=%s",
169
+ status_code,
170
+ path,
171
+ request_id,
172
+ message[:200], # Truncate long messages
173
+ )
174
+
175
+ # Handle specific error types
176
+ if status_code == 404:
177
+ raise AvanzaNotFoundError(f"{context}: {message}")
178
+ elif status_code in (401, 403):
179
+ raise AvanzaAuthError(f"{context}: {message}")
180
+ elif status_code == 429:
181
+ retry_after = response.headers.get("Retry-After")
182
+ retry_after_int = int(retry_after) if retry_after else None
183
+ raise AvanzaRateLimitError(retry_after_int, f"{context}: {message}")
184
+ else:
185
+ try:
186
+ response_dict = response.json()
187
+ except Exception:
188
+ response_dict = None
189
+ raise AvanzaAPIError(status_code, f"{context}: {message}", response_dict)
190
+
191
+ def _is_retryable_status(self, status_code: int) -> bool:
192
+ """Check if HTTP status code is retryable.
193
+
194
+ Args:
195
+ status_code: HTTP status code
196
+
197
+ Returns:
198
+ True if request should be retried
199
+ """
200
+ # Retry on server errors (5xx) but not client errors (4xx)
201
+ # Exception: 429 (rate limit) is handled separately with backoff
202
+ return status_code >= 500
203
+
204
+ async def get(
205
+ self, path: str, params: dict[str, Any] | None = None
206
+ ) -> dict[str, Any]:
207
+ """GET request with retry logic, error handling, and JSON parsing.
208
+
209
+ Automatically retries on transient failures (network errors, timeouts,
210
+ server errors) with exponential backoff.
211
+
212
+ Args:
213
+ path: API endpoint path
214
+ params: Optional query parameters
215
+
216
+ Returns:
217
+ JSON response as dictionary
218
+
219
+ Raises:
220
+ AvanzaError: If request fails after all retries
221
+ """
222
+ if not self._client:
223
+ raise RuntimeError("Client not initialized. Use async context manager.")
224
+
225
+ request_id = self._generate_request_id()
226
+
227
+ @retry(
228
+ retry=retry_if_exception_type(
229
+ (httpx.TimeoutException, httpx.NetworkError, AvanzaRetryableError)
230
+ ),
231
+ stop=stop_after_attempt(self._max_retries),
232
+ wait=wait_exponential(multiplier=1, min=2, max=10),
233
+ reraise=True,
234
+ before_sleep=lambda retry_state: logger.info(
235
+ "Retrying request [%s] %s, attempt %d after %s",
236
+ request_id,
237
+ path,
238
+ retry_state.attempt_number,
239
+ type(retry_state.outcome.exception()).__name__
240
+ if retry_state.outcome
241
+ else "unknown",
242
+ ),
243
+ )
244
+ async def _get_with_retry() -> dict[str, Any]:
245
+ try:
246
+ response = await self._client.get(path, params=params) # type: ignore
247
+ except httpx.TimeoutException as e:
248
+ logger.warning(
249
+ "Request timeout [%s] %s: %s", request_id, path, str(e)
250
+ )
251
+ raise AvanzaTimeoutError(
252
+ f"[{request_id}] Request timeout after {self._timeout}s: {path}"
253
+ ) from e
254
+ except httpx.NetworkError as e:
255
+ logger.warning(
256
+ "Network error [%s] %s: %s", request_id, path, str(e)
257
+ )
258
+ raise AvanzaNetworkError(
259
+ f"[{request_id}] Network error: {path} - {str(e)}"
260
+ ) from e
261
+
262
+ if not response.is_success:
263
+ # Check if this is a retryable server error
264
+ if self._is_retryable_status(response.status_code):
265
+ # Raise specific retryable error to trigger retry
266
+ raise AvanzaRetryableError(
267
+ response.status_code,
268
+ f"[{request_id}] Server error (will retry): {path}",
269
+ )
270
+ # Non-retryable errors
271
+ self._handle_error(response, path, request_id, params)
272
+
273
+ # Handle empty responses
274
+ if not response.content:
275
+ logger.debug("Empty response [%s] %s", request_id, path)
276
+ return {}
277
+
278
+ # Parse JSON response
279
+ try:
280
+ return response.json()
281
+ except Exception as e:
282
+ logger.error(
283
+ "JSON parse error [%s] %s: %s", request_id, path, str(e)
284
+ )
285
+ raise AvanzaAPIError(
286
+ response.status_code,
287
+ f"[{request_id}] Invalid JSON response: {path}",
288
+ ) from e
289
+
290
+ logger.debug("GET [%s] %s params=%s", request_id, path, params)
291
+ return await _get_with_retry()
292
+
293
+ async def post(
294
+ self, path: str, json: dict[str, Any] | None = None
295
+ ) -> dict[str, Any]:
296
+ """POST request with retry logic, error handling, and JSON parsing.
297
+
298
+ Automatically retries on transient failures (network errors, timeouts,
299
+ server errors) with exponential backoff.
300
+
301
+ Args:
302
+ path: API endpoint path
303
+ json: Optional JSON body
304
+
305
+ Returns:
306
+ JSON response as dictionary
307
+
308
+ Raises:
309
+ AvanzaError: If request fails after all retries
310
+ """
311
+ if not self._client:
312
+ raise RuntimeError("Client not initialized. Use async context manager.")
313
+
314
+ request_id = self._generate_request_id()
315
+
316
+ @retry(
317
+ retry=retry_if_exception_type(
318
+ (httpx.TimeoutException, httpx.NetworkError, AvanzaRetryableError)
319
+ ),
320
+ stop=stop_after_attempt(self._max_retries),
321
+ wait=wait_exponential(multiplier=1, min=2, max=10),
322
+ reraise=True,
323
+ before_sleep=lambda retry_state: logger.info(
324
+ "Retrying POST request [%s] %s, attempt %d after %s",
325
+ request_id,
326
+ path,
327
+ retry_state.attempt_number,
328
+ type(retry_state.outcome.exception()).__name__
329
+ if retry_state.outcome
330
+ else "unknown",
331
+ ),
332
+ )
333
+ async def _post_with_retry() -> dict[str, Any]:
334
+ try:
335
+ response = await self._client.post(path, json=json) # type: ignore
336
+ except httpx.TimeoutException as e:
337
+ logger.warning(
338
+ "POST timeout [%s] %s: %s", request_id, path, str(e)
339
+ )
340
+ raise AvanzaTimeoutError(
341
+ f"[{request_id}] Request timeout after {self._timeout}s: {path}"
342
+ ) from e
343
+ except httpx.NetworkError as e:
344
+ logger.warning(
345
+ "POST network error [%s] %s: %s", request_id, path, str(e)
346
+ )
347
+ raise AvanzaNetworkError(
348
+ f"[{request_id}] Network error: {path} - {str(e)}"
349
+ ) from e
350
+
351
+ if not response.is_success:
352
+ if self._is_retryable_status(response.status_code):
353
+ raise AvanzaRetryableError(
354
+ response.status_code,
355
+ f"[{request_id}] Server error (will retry): {path}",
356
+ )
357
+ self._handle_error(response, path, request_id)
358
+
359
+ if not response.content:
360
+ logger.debug("Empty POST response [%s] %s", request_id, path)
361
+ return {}
362
+
363
+ try:
364
+ return response.json()
365
+ except Exception as e:
366
+ logger.error(
367
+ "POST JSON parse error [%s] %s: %s", request_id, path, str(e)
368
+ )
369
+ raise AvanzaAPIError(
370
+ response.status_code,
371
+ f"[{request_id}] Invalid JSON response: {path}",
372
+ ) from e
373
+
374
+ logger.debug("POST [%s] %s", request_id, path)
375
+ return await _post_with_retry()
@@ -0,0 +1,41 @@
1
+ """Avanza API endpoint definitions.
2
+
3
+ All endpoints are public and require no authentication.
4
+ """
5
+
6
+ from enum import Enum
7
+
8
+
9
+ class PublicEndpoint(Enum):
10
+ """Public Avanza API endpoints - no authentication required."""
11
+
12
+ # Search
13
+ SEARCH = "/_api/search/filtered-search"
14
+
15
+ # Market data - Stocks
16
+ STOCK_INFO = "/_api/market-guide/stock/{id}"
17
+ STOCK_ANALYSIS = "/_api/market-guide/stock/{id}/analysis"
18
+ STOCK_QUOTE = "/_api/market-guide/stock/{id}/quote"
19
+ STOCK_MARKETPLACE = "/_api/market-guide/stock/{id}/marketplace"
20
+ STOCK_ORDERDEPTH = "/_api/market-guide/stock/{id}/orderdepth"
21
+ STOCK_TRADES = "/_api/market-guide/stock/{id}/trades"
22
+ STOCK_BROKER_TRADES = "/_api/market-guide/stock/{id}/broker-trade-summaries"
23
+ STOCK_CHART = "/_api/price-chart/stock/{id}" # Requires timePeriod param
24
+
25
+ # Market data - Funds
26
+ FUND_INFO = "/_api/fund-guide/guide/{id}"
27
+ FUND_SUSTAINABILITY = "/_api/fund-reference/sustainability/{id}"
28
+ FUND_CHART = "/_api/fund-guide/chart/{id}/{time_period}" # time_period: three_years, etc.
29
+ FUND_CHART_PERIODS = "/_api/fund-guide/chart/timeperiods/{id}"
30
+ FUND_DESCRIPTION = "/_api/fund-guide/description/{id}"
31
+
32
+ def format(self, **kwargs: str | int) -> str:
33
+ """Format endpoint path with variables.
34
+
35
+ Args:
36
+ **kwargs: Variables to format into the endpoint path
37
+
38
+ Returns:
39
+ Formatted endpoint path
40
+ """
41
+ return self.value.format(**kwargs)
@@ -0,0 +1,66 @@
1
+ """Custom exceptions for Avanza client."""
2
+
3
+
4
+ class AvanzaError(Exception):
5
+ """Base exception for Avanza API errors."""
6
+
7
+ pass
8
+
9
+
10
+ class AvanzaAPIError(AvanzaError):
11
+ """API returned an error response."""
12
+
13
+ def __init__(
14
+ self, status_code: int, message: str, response: dict | None = None
15
+ ) -> None:
16
+ self.status_code = status_code
17
+ self.message = message
18
+ self.response = response
19
+ super().__init__(f"API error {status_code}: {message}")
20
+
21
+
22
+ class AvanzaAuthError(AvanzaError):
23
+ """Authentication failed or token expired."""
24
+
25
+ pass
26
+
27
+
28
+ class AvanzaNotFoundError(AvanzaError):
29
+ """Requested resource not found."""
30
+
31
+ pass
32
+
33
+
34
+ class AvanzaRateLimitError(AvanzaError):
35
+ """Rate limit exceeded."""
36
+
37
+ def __init__(self, retry_after: int | None = None, message: str | None = None) -> None:
38
+ self.retry_after = retry_after
39
+ msg = message or "Rate limit exceeded"
40
+ if retry_after and not message:
41
+ msg += f", retry after {retry_after}s"
42
+ super().__init__(msg)
43
+
44
+
45
+ class AvanzaTimeoutError(AvanzaError):
46
+ """Request timed out."""
47
+
48
+ pass
49
+
50
+
51
+ class AvanzaNetworkError(AvanzaError):
52
+ """Network error occurred during request."""
53
+
54
+ pass
55
+
56
+
57
+ class AvanzaRetryableError(AvanzaError):
58
+ """Transient error that should trigger a retry.
59
+
60
+ This is an internal exception used for retry logic.
61
+ """
62
+
63
+ def __init__(self, status_code: int, message: str) -> None:
64
+ self.status_code = status_code
65
+ self.message = message
66
+ super().__init__(f"Retryable error {status_code}: {message}")
@@ -0,0 +1,41 @@
1
+ """Pydantic models for Avanza API responses."""
2
+
3
+ from .common import InstrumentType, TimePeriod
4
+ from .fund import (
5
+ FundChart,
6
+ FundChartPeriod,
7
+ FundDescription,
8
+ FundInfo,
9
+ FundPerformance,
10
+ FundSustainability,
11
+ )
12
+ from .search import SearchResponse, SearchHit
13
+ from .stock import (
14
+ BrokerTradeSummary,
15
+ MarketplaceInfo,
16
+ OrderDepth,
17
+ Quote,
18
+ StockChart,
19
+ StockInfo,
20
+ Trade,
21
+ )
22
+
23
+ __all__ = [
24
+ "InstrumentType",
25
+ "TimePeriod",
26
+ "SearchResponse",
27
+ "SearchHit",
28
+ "Quote",
29
+ "StockInfo",
30
+ "FundInfo",
31
+ "FundPerformance",
32
+ "FundChart",
33
+ "FundChartPeriod",
34
+ "FundDescription",
35
+ "FundSustainability",
36
+ "StockChart",
37
+ "MarketplaceInfo",
38
+ "BrokerTradeSummary",
39
+ "Trade",
40
+ "OrderDepth",
41
+ ]
@@ -0,0 +1,50 @@
1
+ """Common models and enums shared across Avanza API."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class InstrumentType(str, Enum):
7
+ """Types of financial instruments available on Avanza."""
8
+
9
+ STOCK = "STOCK"
10
+ FUND = "FUND"
11
+ BOND = "BOND"
12
+ OPTION = "OPTION"
13
+ FUTURE_FORWARD = "FUTURE_FORWARD"
14
+ CERTIFICATE = "CERTIFICATE"
15
+ WARRANT = "WARRANT"
16
+ ETF = "ETF"
17
+ EXCHANGE_TRADED_FUND = "EXCHANGE_TRADED_FUND"
18
+ INDEX = "INDEX"
19
+ PREMIUM_BOND = "PREMIUM_BOND"
20
+ SUBSCRIPTION_OPTION = "SUBSCRIPTION_OPTION"
21
+ EQUITY_LINKED_BOND = "EQUITY_LINKED_BOND"
22
+ CONVERTIBLE = "CONVERTIBLE"
23
+ FAQ = "FAQ"
24
+
25
+
26
+ class TimePeriod(str, Enum):
27
+ """Time periods for chart data and performance metrics."""
28
+
29
+ TODAY = "TODAY"
30
+ ONE_WEEK = "ONE_WEEK"
31
+ ONE_MONTH = "ONE_MONTH"
32
+ THREE_MONTHS = "THREE_MONTHS"
33
+ THIS_YEAR = "THIS_YEAR"
34
+ ONE_YEAR = "ONE_YEAR"
35
+ THREE_YEARS = "THREE_YEARS"
36
+ FIVE_YEARS = "FIVE_YEARS"
37
+ ALL_TIME = "ALL_TIME"
38
+
39
+
40
+ class Resolution(str, Enum):
41
+ """Chart resolution/granularity."""
42
+
43
+ MINUTE = "MINUTE"
44
+ FIVE_MINUTES = "FIVE_MINUTES"
45
+ TEN_MINUTES = "TEN_MINUTES"
46
+ THIRTY_MINUTES = "THIRTY_MINUTES"
47
+ HOUR = "HOUR"
48
+ DAY = "DAY"
49
+ WEEK = "WEEK"
50
+ MONTH = "MONTH"