ib-mcp 0.2.5__tar.gz
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.
- ib_mcp-0.2.5/LICENSE +14 -0
- ib_mcp-0.2.5/PKG-INFO +296 -0
- ib_mcp-0.2.5/README.md +269 -0
- ib_mcp-0.2.5/ib_mcp/__init__.py +3 -0
- ib_mcp-0.2.5/ib_mcp/server.py +615 -0
- ib_mcp-0.2.5/pyproject.toml +73 -0
ib_mcp-0.2.5/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025, David Hellekalek
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
9
|
+
|
|
10
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
11
|
+
|
|
12
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
13
|
+
|
|
14
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
ib_mcp-0.2.5/PKG-INFO
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: ib-mcp
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Model Context Protocol (MCP) server exposing Interactive Brokers data via ib_async + FastMCP
|
|
5
|
+
Home-page: https://github.com/Hellek1/ib-mcp
|
|
6
|
+
License: BSD-3-Clause
|
|
7
|
+
Keywords: interactive-brokers,ib,ibapi,tws,asyncio,mcp,fastmcp,llm
|
|
8
|
+
Author: David Hellekalek
|
|
9
|
+
Author-email: hellekalek@gmail.com
|
|
10
|
+
Requires-Python: >=3.12,<4.0
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Dist: defusedxml (>=0.7.1)
|
|
21
|
+
Requires-Dist: fastmcp (>=2.11.0)
|
|
22
|
+
Requires-Dist: ib_async (>=2.0.1,<3.0.0)
|
|
23
|
+
Project-URL: Documentation, https://github.com/Hellek1/ib-mcp#readme
|
|
24
|
+
Project-URL: Repository, https://github.com/Hellek1/ib-mcp
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# IB Async MCP Server
|
|
28
|
+
|
|
29
|
+
[](https://github.com/Hellek1/ib-mcp/actions/workflows/ci.yml)
|
|
30
|
+
|
|
31
|
+
Lightweight Model Context Protocol (MCP) server exposing read-only Interactive Brokers data (contracts, historical data, fundamentals, news, portfolio, account) via the asynchronous [`ib_async`](https://ib-api-reloaded.github.io/ib_async/) library and [`FastMCP`](https://github.com/modelcontextprotocol/fastmcp). Ideal for feeding financial data into LLM workflows and autonomous agents while keeping trading disabled.
|
|
32
|
+
|
|
33
|
+
## Overview
|
|
34
|
+
|
|
35
|
+
This directory contains an MCP (Model Context Protocol) server that wraps the ib_async library to allow LLMs to interact with Interactive Brokers data.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
The MCP server provides the following tools for LLM interaction:
|
|
40
|
+
|
|
41
|
+
### 1. Contract Lookup and Conversion
|
|
42
|
+
- **lookup_contract**: Look up contract details by ticker symbol and optional exchange/currency
|
|
43
|
+
- **ticker_to_conid**: Convert ticker symbol to contract ID (conid)
|
|
44
|
+
|
|
45
|
+
### 2. Market Data
|
|
46
|
+
- **get_historical_data**: Retrieve historical market data with configurable duration, bar size, and data type
|
|
47
|
+
|
|
48
|
+
### 3. News
|
|
49
|
+
- **get_news**: Retrieve current news articles for a contract
|
|
50
|
+
- **get_historical_news**: Retrieve historical news articles within a date range
|
|
51
|
+
|
|
52
|
+
### 4. Fundamental Data
|
|
53
|
+
- **get_fundamental_data**: Retrieve fundamental data including financial summaries, ownership, financial statements, and more
|
|
54
|
+
|
|
55
|
+
### 5. Portfolio and Account Information
|
|
56
|
+
- **get_portfolio**: Retrieve portfolio positions and details
|
|
57
|
+
- **get_account_summary**: Retrieve account summary information
|
|
58
|
+
- **get_positions**: Retrieve current positions
|
|
59
|
+
|
|
60
|
+
## Prerequisites
|
|
61
|
+
|
|
62
|
+
1. **Interactive Brokers Account**: You need an active IB account
|
|
63
|
+
2. **IB Gateway or TWS**: Download and install either:
|
|
64
|
+
- [IB Gateway (Stable)](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php) - Recommended for API-only use
|
|
65
|
+
- [IB Gateway (Latest)](https://www.interactivebrokers.com/en/trading/ibgateway-latest.php) - Latest features
|
|
66
|
+
- [Trader Workstation (TWS)](https://www.interactivebrokers.com/en/trading/tws.php) - Full trading platform
|
|
67
|
+
|
|
68
|
+
3. **API Configuration**:
|
|
69
|
+
- Enable API access in TWS/Gateway: `Configure → API → Settings` and check "Enable ActiveX and Socket Clients"
|
|
70
|
+
- Set appropriate port (default: 7497 for TWS, 4001 for Gateway)
|
|
71
|
+
- Add `127.0.0.1` to trusted IPs if connecting locally
|
|
72
|
+
|
|
73
|
+
## Installation
|
|
74
|
+
|
|
75
|
+
### From source (development)
|
|
76
|
+
```bash
|
|
77
|
+
git clone https://github.com/Hellek1/ib-mcp.git
|
|
78
|
+
cd ib-mcp
|
|
79
|
+
pip install poetry
|
|
80
|
+
poetry install
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Usage
|
|
84
|
+
|
|
85
|
+
### Running the MCP Server
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Using default settings (TWS on localhost:7497)
|
|
89
|
+
poetry run ib-mcp-server
|
|
90
|
+
|
|
91
|
+
# Custom IB Gateway connection
|
|
92
|
+
poetry run ib-mcp-server --host 127.0.0.1 --port 4001 --client-id 1
|
|
93
|
+
|
|
94
|
+
# Help with all options
|
|
95
|
+
poetry run ib-mcp-server --help
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Command Line Options
|
|
99
|
+
|
|
100
|
+
- `--host`: IB Gateway/TWS host (default: 127.0.0.1)
|
|
101
|
+
- `--port`: IB Gateway/TWS port (default: 7497 for TWS, use 4001 for Gateway)
|
|
102
|
+
- `--client-id`: Unique client ID for the connection (default: 1)
|
|
103
|
+
|
|
104
|
+
You can also use environment variables instead of flags:
|
|
105
|
+
|
|
106
|
+
- `IB_HOST`
|
|
107
|
+
- `IB_PORT`
|
|
108
|
+
- `IB_CLIENT_ID`
|
|
109
|
+
|
|
110
|
+
Flags override environment variables if both are provided.
|
|
111
|
+
|
|
112
|
+
## Docker
|
|
113
|
+
|
|
114
|
+
### Build
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
docker build -t ib-mcp .
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Run (connect to TWS running on host)
|
|
121
|
+
|
|
122
|
+
On macOS/Windows Docker Desktop you can reach host via `host.docker.internal` (already the default):
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
docker run --rm -it \
|
|
126
|
+
-e IB_HOST=host.docker.internal \
|
|
127
|
+
-e IB_PORT=7497 \
|
|
128
|
+
-e IB_CLIENT_ID=1 \
|
|
129
|
+
ghcr.io/hellek1/ib-mcp
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
On Linux you may need to add `--add-host host.docker.internal:host-gateway` and ensure the TWS/Gateway port is accessible:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
docker run --rm -it \
|
|
136
|
+
--add-host host.docker.internal:host-gateway \
|
|
137
|
+
-e IB_HOST=host.docker.internal \
|
|
138
|
+
-e IB_PORT=7497 \
|
|
139
|
+
ghcr.io/hellek1/ib-mcp
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Override arguments directly if preferred:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
docker run --rm -it ghcr.io/hellek1/ib-mcp --host host.docker.internal --port 4001 --client-id 2
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
### MCP Client Integration
|
|
150
|
+
|
|
151
|
+
The server communicates via stdio using the MCP protocol. It can be integrated with MCP-compatible tools and LLM applications.
|
|
152
|
+
|
|
153
|
+
Example MCP client configuration (e.g. Claude Desktop) using Docker:
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"mcpServers": {
|
|
157
|
+
"ib-async": {
|
|
158
|
+
"command": "docker",
|
|
159
|
+
"args": [
|
|
160
|
+
"run",
|
|
161
|
+
"--rm",
|
|
162
|
+
"--add-host","host.docker.internal:host-gateway",
|
|
163
|
+
"-e","IB_HOST=host.docker.internal",
|
|
164
|
+
"-e","IB_PORT=7497",
|
|
165
|
+
"-e","IB_CLIENT_ID=1",
|
|
166
|
+
"ghcr.io/hellek1/ib-mcp:latest"
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Notes:
|
|
174
|
+
1. Remove the `--add-host` line on macOS/Windows Docker Desktop (it's only needed on Linux).
|
|
175
|
+
|
|
176
|
+
## Available Tools
|
|
177
|
+
|
|
178
|
+
### Contract Lookup
|
|
179
|
+
```
|
|
180
|
+
lookup_contract(symbol, sec_type="STK", exchange="SMART", currency="USD")
|
|
181
|
+
ticker_to_conid(symbol, sec_type="STK", exchange="SMART", currency="USD")
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Market Data
|
|
185
|
+
```
|
|
186
|
+
get_historical_data(symbol, duration="1 M", bar_size="1 day", data_type="TRADES", exchange="SMART", currency="USD")
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### News
|
|
190
|
+
```
|
|
191
|
+
get_news(symbol, provider_codes="", exchange="SMART", currency="USD")
|
|
192
|
+
get_historical_news(symbol, start_date, end_date, provider_codes="", max_count=10, exchange="SMART", currency="USD")
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Fundamentals
|
|
196
|
+
```
|
|
197
|
+
get_fundamental_data(symbol, report_type="ReportsFinSummary", exchange="SMART", currency="USD")
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Available report types:
|
|
201
|
+
- `ReportsFinSummary`: Financial summary
|
|
202
|
+
- `ReportsOwnership`: Ownership information
|
|
203
|
+
- `ReportsFinStatements`: Financial statements
|
|
204
|
+
- `RESC`: Research reports
|
|
205
|
+
- `CalendarReport`: Calendar events
|
|
206
|
+
|
|
207
|
+
### Portfolio & Account
|
|
208
|
+
```
|
|
209
|
+
get_portfolio(account="")
|
|
210
|
+
get_account_summary(account="")
|
|
211
|
+
get_positions(account="")
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Example Usage
|
|
215
|
+
|
|
216
|
+
Once connected to an LLM through MCP, you can ask questions like:
|
|
217
|
+
|
|
218
|
+
- "Look up the contract details for AAPL"
|
|
219
|
+
- "Get the last month of daily historical data for TSLA"
|
|
220
|
+
- "What are the recent news articles for Microsoft?"
|
|
221
|
+
- "Show me the financial summary for Google"
|
|
222
|
+
- "What positions do I currently have in my portfolio?"
|
|
223
|
+
|
|
224
|
+
## Data Formats
|
|
225
|
+
|
|
226
|
+
### XML to Markdown Conversion
|
|
227
|
+
|
|
228
|
+
The server automatically converts XML-formatted fundamental data to markdown for better readability in LLM interactions.
|
|
229
|
+
|
|
230
|
+
### Error Handling
|
|
231
|
+
|
|
232
|
+
The server includes comprehensive error handling and will provide meaningful error messages when:
|
|
233
|
+
- IB connection fails
|
|
234
|
+
- Invalid symbols are requested
|
|
235
|
+
- Market data is not available
|
|
236
|
+
- Authentication issues occur
|
|
237
|
+
|
|
238
|
+
## Troubleshooting
|
|
239
|
+
|
|
240
|
+
### Connection Issues
|
|
241
|
+
|
|
242
|
+
1. **"Cannot connect to Interactive Brokers"**
|
|
243
|
+
- Ensure TWS/Gateway is running
|
|
244
|
+
- Check that API is enabled in settings
|
|
245
|
+
- Verify port numbers match (7497 for TWS, 4001 for Gateway)
|
|
246
|
+
- Check firewall settings
|
|
247
|
+
|
|
248
|
+
2. **"No contract found"**
|
|
249
|
+
- Verify symbol spelling
|
|
250
|
+
- Try different exchanges (NYSE, NASDAQ vs SMART)
|
|
251
|
+
- Check if security type is correct
|
|
252
|
+
|
|
253
|
+
3. **"No market data"**
|
|
254
|
+
- Ensure you have appropriate market data subscriptions
|
|
255
|
+
- Check if markets are open for real-time data
|
|
256
|
+
- Try delayed data mode if real-time is not available
|
|
257
|
+
|
|
258
|
+
### Performance Tips
|
|
259
|
+
|
|
260
|
+
1. Use specific exchanges when possible instead of "SMART" routing
|
|
261
|
+
2. Limit historical data requests to reasonable time ranges
|
|
262
|
+
3. Cache contract IDs for frequently accessed symbols
|
|
263
|
+
|
|
264
|
+
## Security Considerations
|
|
265
|
+
|
|
266
|
+
- The MCP server operates in read-only mode - no order placement capabilities
|
|
267
|
+
- Credentials are handled by the IB Gateway/TWS application
|
|
268
|
+
- The server only accesses data you have permission to view in your IB account
|
|
269
|
+
|
|
270
|
+
## Contributing
|
|
271
|
+
|
|
272
|
+
1. Fork & branch: `feat/xyz`
|
|
273
|
+
2. Install dev deps: `poetry install`
|
|
274
|
+
3. Activate pre-commit: `pre-commit install`
|
|
275
|
+
4. Run tests: `poetry run pytest -q`
|
|
276
|
+
5. Open a PR with a concise description.
|
|
277
|
+
|
|
278
|
+
### Release (maintainers)
|
|
279
|
+
```bash
|
|
280
|
+
poetry version patch # or minor / major
|
|
281
|
+
poetry build
|
|
282
|
+
poetry publish --username __token__ --password <pypi-token>
|
|
283
|
+
git tag v$(poetry version -s)
|
|
284
|
+
git push --tags
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
## Support & References
|
|
288
|
+
|
|
289
|
+
- IB API functionality: [ib_async docs](https://ib-api-reloaded.github.io/ib_async/)
|
|
290
|
+
- MCP protocol: [MCP spec](https://spec.modelcontextprotocol.io/)
|
|
291
|
+
- Interactive Brokers: [IB API docs](https://ibkrcampus.com/ibkr-api-page/twsapi-doc/)
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
Licensed under the BSD 3-Clause License. Contributions welcome.
|
|
296
|
+
|
ib_mcp-0.2.5/README.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# IB Async MCP Server
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Hellek1/ib-mcp/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Lightweight Model Context Protocol (MCP) server exposing read-only Interactive Brokers data (contracts, historical data, fundamentals, news, portfolio, account) via the asynchronous [`ib_async`](https://ib-api-reloaded.github.io/ib_async/) library and [`FastMCP`](https://github.com/modelcontextprotocol/fastmcp). Ideal for feeding financial data into LLM workflows and autonomous agents while keeping trading disabled.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
This directory contains an MCP (Model Context Protocol) server that wraps the ib_async library to allow LLMs to interact with Interactive Brokers data.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
The MCP server provides the following tools for LLM interaction:
|
|
14
|
+
|
|
15
|
+
### 1. Contract Lookup and Conversion
|
|
16
|
+
- **lookup_contract**: Look up contract details by ticker symbol and optional exchange/currency
|
|
17
|
+
- **ticker_to_conid**: Convert ticker symbol to contract ID (conid)
|
|
18
|
+
|
|
19
|
+
### 2. Market Data
|
|
20
|
+
- **get_historical_data**: Retrieve historical market data with configurable duration, bar size, and data type
|
|
21
|
+
|
|
22
|
+
### 3. News
|
|
23
|
+
- **get_news**: Retrieve current news articles for a contract
|
|
24
|
+
- **get_historical_news**: Retrieve historical news articles within a date range
|
|
25
|
+
|
|
26
|
+
### 4. Fundamental Data
|
|
27
|
+
- **get_fundamental_data**: Retrieve fundamental data including financial summaries, ownership, financial statements, and more
|
|
28
|
+
|
|
29
|
+
### 5. Portfolio and Account Information
|
|
30
|
+
- **get_portfolio**: Retrieve portfolio positions and details
|
|
31
|
+
- **get_account_summary**: Retrieve account summary information
|
|
32
|
+
- **get_positions**: Retrieve current positions
|
|
33
|
+
|
|
34
|
+
## Prerequisites
|
|
35
|
+
|
|
36
|
+
1. **Interactive Brokers Account**: You need an active IB account
|
|
37
|
+
2. **IB Gateway or TWS**: Download and install either:
|
|
38
|
+
- [IB Gateway (Stable)](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php) - Recommended for API-only use
|
|
39
|
+
- [IB Gateway (Latest)](https://www.interactivebrokers.com/en/trading/ibgateway-latest.php) - Latest features
|
|
40
|
+
- [Trader Workstation (TWS)](https://www.interactivebrokers.com/en/trading/tws.php) - Full trading platform
|
|
41
|
+
|
|
42
|
+
3. **API Configuration**:
|
|
43
|
+
- Enable API access in TWS/Gateway: `Configure → API → Settings` and check "Enable ActiveX and Socket Clients"
|
|
44
|
+
- Set appropriate port (default: 7497 for TWS, 4001 for Gateway)
|
|
45
|
+
- Add `127.0.0.1` to trusted IPs if connecting locally
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
### From source (development)
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/Hellek1/ib-mcp.git
|
|
52
|
+
cd ib-mcp
|
|
53
|
+
pip install poetry
|
|
54
|
+
poetry install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
### Running the MCP Server
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Using default settings (TWS on localhost:7497)
|
|
63
|
+
poetry run ib-mcp-server
|
|
64
|
+
|
|
65
|
+
# Custom IB Gateway connection
|
|
66
|
+
poetry run ib-mcp-server --host 127.0.0.1 --port 4001 --client-id 1
|
|
67
|
+
|
|
68
|
+
# Help with all options
|
|
69
|
+
poetry run ib-mcp-server --help
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Command Line Options
|
|
73
|
+
|
|
74
|
+
- `--host`: IB Gateway/TWS host (default: 127.0.0.1)
|
|
75
|
+
- `--port`: IB Gateway/TWS port (default: 7497 for TWS, use 4001 for Gateway)
|
|
76
|
+
- `--client-id`: Unique client ID for the connection (default: 1)
|
|
77
|
+
|
|
78
|
+
You can also use environment variables instead of flags:
|
|
79
|
+
|
|
80
|
+
- `IB_HOST`
|
|
81
|
+
- `IB_PORT`
|
|
82
|
+
- `IB_CLIENT_ID`
|
|
83
|
+
|
|
84
|
+
Flags override environment variables if both are provided.
|
|
85
|
+
|
|
86
|
+
## Docker
|
|
87
|
+
|
|
88
|
+
### Build
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
docker build -t ib-mcp .
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Run (connect to TWS running on host)
|
|
95
|
+
|
|
96
|
+
On macOS/Windows Docker Desktop you can reach host via `host.docker.internal` (already the default):
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
docker run --rm -it \
|
|
100
|
+
-e IB_HOST=host.docker.internal \
|
|
101
|
+
-e IB_PORT=7497 \
|
|
102
|
+
-e IB_CLIENT_ID=1 \
|
|
103
|
+
ghcr.io/hellek1/ib-mcp
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
On Linux you may need to add `--add-host host.docker.internal:host-gateway` and ensure the TWS/Gateway port is accessible:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
docker run --rm -it \
|
|
110
|
+
--add-host host.docker.internal:host-gateway \
|
|
111
|
+
-e IB_HOST=host.docker.internal \
|
|
112
|
+
-e IB_PORT=7497 \
|
|
113
|
+
ghcr.io/hellek1/ib-mcp
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Override arguments directly if preferred:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
docker run --rm -it ghcr.io/hellek1/ib-mcp --host host.docker.internal --port 4001 --client-id 2
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
### MCP Client Integration
|
|
124
|
+
|
|
125
|
+
The server communicates via stdio using the MCP protocol. It can be integrated with MCP-compatible tools and LLM applications.
|
|
126
|
+
|
|
127
|
+
Example MCP client configuration (e.g. Claude Desktop) using Docker:
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"mcpServers": {
|
|
131
|
+
"ib-async": {
|
|
132
|
+
"command": "docker",
|
|
133
|
+
"args": [
|
|
134
|
+
"run",
|
|
135
|
+
"--rm",
|
|
136
|
+
"--add-host","host.docker.internal:host-gateway",
|
|
137
|
+
"-e","IB_HOST=host.docker.internal",
|
|
138
|
+
"-e","IB_PORT=7497",
|
|
139
|
+
"-e","IB_CLIENT_ID=1",
|
|
140
|
+
"ghcr.io/hellek1/ib-mcp:latest"
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Notes:
|
|
148
|
+
1. Remove the `--add-host` line on macOS/Windows Docker Desktop (it's only needed on Linux).
|
|
149
|
+
|
|
150
|
+
## Available Tools
|
|
151
|
+
|
|
152
|
+
### Contract Lookup
|
|
153
|
+
```
|
|
154
|
+
lookup_contract(symbol, sec_type="STK", exchange="SMART", currency="USD")
|
|
155
|
+
ticker_to_conid(symbol, sec_type="STK", exchange="SMART", currency="USD")
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Market Data
|
|
159
|
+
```
|
|
160
|
+
get_historical_data(symbol, duration="1 M", bar_size="1 day", data_type="TRADES", exchange="SMART", currency="USD")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### News
|
|
164
|
+
```
|
|
165
|
+
get_news(symbol, provider_codes="", exchange="SMART", currency="USD")
|
|
166
|
+
get_historical_news(symbol, start_date, end_date, provider_codes="", max_count=10, exchange="SMART", currency="USD")
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Fundamentals
|
|
170
|
+
```
|
|
171
|
+
get_fundamental_data(symbol, report_type="ReportsFinSummary", exchange="SMART", currency="USD")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Available report types:
|
|
175
|
+
- `ReportsFinSummary`: Financial summary
|
|
176
|
+
- `ReportsOwnership`: Ownership information
|
|
177
|
+
- `ReportsFinStatements`: Financial statements
|
|
178
|
+
- `RESC`: Research reports
|
|
179
|
+
- `CalendarReport`: Calendar events
|
|
180
|
+
|
|
181
|
+
### Portfolio & Account
|
|
182
|
+
```
|
|
183
|
+
get_portfolio(account="")
|
|
184
|
+
get_account_summary(account="")
|
|
185
|
+
get_positions(account="")
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Example Usage
|
|
189
|
+
|
|
190
|
+
Once connected to an LLM through MCP, you can ask questions like:
|
|
191
|
+
|
|
192
|
+
- "Look up the contract details for AAPL"
|
|
193
|
+
- "Get the last month of daily historical data for TSLA"
|
|
194
|
+
- "What are the recent news articles for Microsoft?"
|
|
195
|
+
- "Show me the financial summary for Google"
|
|
196
|
+
- "What positions do I currently have in my portfolio?"
|
|
197
|
+
|
|
198
|
+
## Data Formats
|
|
199
|
+
|
|
200
|
+
### XML to Markdown Conversion
|
|
201
|
+
|
|
202
|
+
The server automatically converts XML-formatted fundamental data to markdown for better readability in LLM interactions.
|
|
203
|
+
|
|
204
|
+
### Error Handling
|
|
205
|
+
|
|
206
|
+
The server includes comprehensive error handling and will provide meaningful error messages when:
|
|
207
|
+
- IB connection fails
|
|
208
|
+
- Invalid symbols are requested
|
|
209
|
+
- Market data is not available
|
|
210
|
+
- Authentication issues occur
|
|
211
|
+
|
|
212
|
+
## Troubleshooting
|
|
213
|
+
|
|
214
|
+
### Connection Issues
|
|
215
|
+
|
|
216
|
+
1. **"Cannot connect to Interactive Brokers"**
|
|
217
|
+
- Ensure TWS/Gateway is running
|
|
218
|
+
- Check that API is enabled in settings
|
|
219
|
+
- Verify port numbers match (7497 for TWS, 4001 for Gateway)
|
|
220
|
+
- Check firewall settings
|
|
221
|
+
|
|
222
|
+
2. **"No contract found"**
|
|
223
|
+
- Verify symbol spelling
|
|
224
|
+
- Try different exchanges (NYSE, NASDAQ vs SMART)
|
|
225
|
+
- Check if security type is correct
|
|
226
|
+
|
|
227
|
+
3. **"No market data"**
|
|
228
|
+
- Ensure you have appropriate market data subscriptions
|
|
229
|
+
- Check if markets are open for real-time data
|
|
230
|
+
- Try delayed data mode if real-time is not available
|
|
231
|
+
|
|
232
|
+
### Performance Tips
|
|
233
|
+
|
|
234
|
+
1. Use specific exchanges when possible instead of "SMART" routing
|
|
235
|
+
2. Limit historical data requests to reasonable time ranges
|
|
236
|
+
3. Cache contract IDs for frequently accessed symbols
|
|
237
|
+
|
|
238
|
+
## Security Considerations
|
|
239
|
+
|
|
240
|
+
- The MCP server operates in read-only mode - no order placement capabilities
|
|
241
|
+
- Credentials are handled by the IB Gateway/TWS application
|
|
242
|
+
- The server only accesses data you have permission to view in your IB account
|
|
243
|
+
|
|
244
|
+
## Contributing
|
|
245
|
+
|
|
246
|
+
1. Fork & branch: `feat/xyz`
|
|
247
|
+
2. Install dev deps: `poetry install`
|
|
248
|
+
3. Activate pre-commit: `pre-commit install`
|
|
249
|
+
4. Run tests: `poetry run pytest -q`
|
|
250
|
+
5. Open a PR with a concise description.
|
|
251
|
+
|
|
252
|
+
### Release (maintainers)
|
|
253
|
+
```bash
|
|
254
|
+
poetry version patch # or minor / major
|
|
255
|
+
poetry build
|
|
256
|
+
poetry publish --username __token__ --password <pypi-token>
|
|
257
|
+
git tag v$(poetry version -s)
|
|
258
|
+
git push --tags
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Support & References
|
|
262
|
+
|
|
263
|
+
- IB API functionality: [ib_async docs](https://ib-api-reloaded.github.io/ib_async/)
|
|
264
|
+
- MCP protocol: [MCP spec](https://spec.modelcontextprotocol.io/)
|
|
265
|
+
- Interactive Brokers: [IB API docs](https://ibkrcampus.com/ibkr-api-page/twsapi-doc/)
|
|
266
|
+
|
|
267
|
+
---
|
|
268
|
+
|
|
269
|
+
Licensed under the BSD 3-Clause License. Contributions welcome.
|
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"""FastMCP-based MCP server for Interactive Brokers (alternate implementation).
|
|
2
|
+
|
|
3
|
+
This mirrors the tools and structure from the legacy server, but uses FastMCP
|
|
4
|
+
for simpler registration and JSON-schema generation from type hints.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Annotated, Any
|
|
11
|
+
|
|
12
|
+
import defusedxml.ElementTree as ET
|
|
13
|
+
import ib_async as ib
|
|
14
|
+
from fastmcp import FastMCP
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IBMCPServer:
|
|
21
|
+
"""Interactive Brokers MCP Server (FastMCP edition)."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self, host: str = "127.0.0.1", port: int = 7496, client_id: int = 1
|
|
25
|
+
) -> None:
|
|
26
|
+
self.server = FastMCP("IBKR MCP Server")
|
|
27
|
+
self.ib = ib.IB()
|
|
28
|
+
self.host = host
|
|
29
|
+
self.port = port
|
|
30
|
+
self.client_id = client_id
|
|
31
|
+
self.connected = False
|
|
32
|
+
self.news_provider_codes: str = ""
|
|
33
|
+
|
|
34
|
+
# Register FastMCP tools
|
|
35
|
+
self._register_handlers()
|
|
36
|
+
|
|
37
|
+
def _register_handlers(self) -> None:
|
|
38
|
+
"""Register tools using FastMCP decorators. Uses closures that capture self."""
|
|
39
|
+
|
|
40
|
+
async def _ensure_connected() -> None:
|
|
41
|
+
if self.connected:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
await self.ib.connectAsync(
|
|
45
|
+
self.host, self.port, self.client_id, readonly=True
|
|
46
|
+
)
|
|
47
|
+
self.connected = True
|
|
48
|
+
logger.info("Connected to IB at %s:%s", self.host, self.port)
|
|
49
|
+
news_providers = await self.ib.reqNewsProvidersAsync()
|
|
50
|
+
self.news_provider_codes = "+".join(np.code for np in news_providers)
|
|
51
|
+
logger.info("News providers retrieved: %s", self.news_provider_codes)
|
|
52
|
+
except Exception as e: # pragma: no cover - relies on external service
|
|
53
|
+
logger.error("Failed to connect to IB: %s", e)
|
|
54
|
+
raise ConnectionError(
|
|
55
|
+
f"Cannot connect to Interactive Brokers: {e}"
|
|
56
|
+
) from e
|
|
57
|
+
|
|
58
|
+
def _create_contract(
|
|
59
|
+
symbol: str,
|
|
60
|
+
sec_type: str = "STK",
|
|
61
|
+
exchange: str = "SMART",
|
|
62
|
+
currency: str = "USD",
|
|
63
|
+
) -> ib.Contract:
|
|
64
|
+
if symbol.isdigit():
|
|
65
|
+
return ib.Contract(conId=int(symbol))
|
|
66
|
+
if sec_type == "STK":
|
|
67
|
+
return ib.Stock(symbol=symbol, exchange=exchange, currency=currency)
|
|
68
|
+
if sec_type in ("FOREX", "CASH"):
|
|
69
|
+
return ib.Forex(pair=symbol)
|
|
70
|
+
if sec_type == "FUT":
|
|
71
|
+
return ib.Future(symbol=symbol, exchange=exchange)
|
|
72
|
+
if sec_type == "OPT":
|
|
73
|
+
# Option expects strike as float, not currency as 3rd arg
|
|
74
|
+
return ib.Option(symbol=symbol, exchange=exchange, currency=currency)
|
|
75
|
+
return ib.Contract(
|
|
76
|
+
symbol=symbol, secType=sec_type, exchange=exchange, currency=currency
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _flatten_contracts(contracts: list[Any]) -> list[ib.Contract]:
|
|
80
|
+
# Recursively flatten nested contract lists and filter out None
|
|
81
|
+
result: list[ib.Contract] = []
|
|
82
|
+
for c in contracts:
|
|
83
|
+
if isinstance(c, ib.Contract):
|
|
84
|
+
result.append(c)
|
|
85
|
+
elif isinstance(c, list):
|
|
86
|
+
result.extend(_flatten_contracts(c))
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
def _xml_to_markdown(xml_data: str) -> str:
|
|
90
|
+
"""Convert XML data to markdown; return as-is if not XML."""
|
|
91
|
+
try:
|
|
92
|
+
if not xml_data or not xml_data.strip().startswith("<"):
|
|
93
|
+
return xml_data
|
|
94
|
+
root = ET.fromstring(xml_data)
|
|
95
|
+
return _xml_element_to_markdown(root)
|
|
96
|
+
except ET.ParseError:
|
|
97
|
+
return xml_data
|
|
98
|
+
|
|
99
|
+
def _xml_element_to_markdown(element: ET.Element, level: int = 0) -> str:
|
|
100
|
+
markdown = ""
|
|
101
|
+
indent = " " * level
|
|
102
|
+
if level == 0:
|
|
103
|
+
markdown += f"# {element.tag}\n\n"
|
|
104
|
+
elif level == 1:
|
|
105
|
+
markdown += f"## {element.tag}\n\n"
|
|
106
|
+
elif level == 2:
|
|
107
|
+
markdown += f"### {element.tag}\n\n"
|
|
108
|
+
else:
|
|
109
|
+
markdown += f"{indent}**{element.tag}**\n\n"
|
|
110
|
+
if element.text and element.text.strip():
|
|
111
|
+
markdown += f"{indent}{element.text.strip()}\n\n"
|
|
112
|
+
if element.attrib:
|
|
113
|
+
for key, value in element.attrib.items():
|
|
114
|
+
markdown += f"{indent}- **{key}**: {value}\n"
|
|
115
|
+
markdown += "\n"
|
|
116
|
+
for child in element:
|
|
117
|
+
markdown += _xml_element_to_markdown(child, level + 1)
|
|
118
|
+
return markdown
|
|
119
|
+
|
|
120
|
+
@self.server.tool(
|
|
121
|
+
description="Look up contract details by ticker symbol and optional exchange/currency"
|
|
122
|
+
)
|
|
123
|
+
async def lookup_contract(
|
|
124
|
+
symbol: Annotated[str, "Stock symbol (e.g., AAPL, GOOGL, etc.)"],
|
|
125
|
+
sec_type: Annotated[
|
|
126
|
+
str, "Security type (e.g., STK, OPT, FUT, etc.)"
|
|
127
|
+
] = "STK",
|
|
128
|
+
exchange: Annotated[
|
|
129
|
+
str, "Exchange (e.g., SMART, NYSE, NASDAQ, etc.)"
|
|
130
|
+
] = "SMART",
|
|
131
|
+
currency: Annotated[str, "Currency (e.g., USD, EUR, etc.)"] = "USD",
|
|
132
|
+
) -> str:
|
|
133
|
+
await _ensure_connected()
|
|
134
|
+
contract = _create_contract(symbol, sec_type, exchange, currency)
|
|
135
|
+
try:
|
|
136
|
+
contracts_raw = await self.ib.qualifyContractsAsync(contract)
|
|
137
|
+
contracts = _flatten_contracts(contracts_raw)
|
|
138
|
+
if not contracts:
|
|
139
|
+
return f"No contract found for {symbol}"
|
|
140
|
+
lines = [f"Found {len(contracts)} contract(s) for {symbol}:"]
|
|
141
|
+
for i, c in enumerate(contracts, 1):
|
|
142
|
+
if c is None:
|
|
143
|
+
continue
|
|
144
|
+
lines.extend(
|
|
145
|
+
[
|
|
146
|
+
f"Contract {i}:",
|
|
147
|
+
f" ConID: {getattr(c, 'conId', '')}",
|
|
148
|
+
f" Symbol: {getattr(c, 'symbol', '')}",
|
|
149
|
+
f" SecType: {getattr(c, 'secType', '')}",
|
|
150
|
+
f" Exchange: {getattr(c, 'exchange', '')}",
|
|
151
|
+
f" Primary Exchange: {getattr(c, 'primaryExchange', '')}",
|
|
152
|
+
f" Currency: {getattr(c, 'currency', '')}",
|
|
153
|
+
f" Trading Class: {getattr(c, 'tradingClass', '')}",
|
|
154
|
+
f" Local Symbol: {getattr(c, 'localSymbol', '')}",
|
|
155
|
+
"",
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
except Exception as e: # pragma: no cover - depends on network
|
|
160
|
+
return f"Error looking up contract: {e}"
|
|
161
|
+
|
|
162
|
+
@self.server.tool(description="Convert ticker symbol to contract ID (conid)")
|
|
163
|
+
async def ticker_to_conid(
|
|
164
|
+
symbol: str,
|
|
165
|
+
sec_type: str = "STK",
|
|
166
|
+
exchange: str = "SMART",
|
|
167
|
+
currency: str = "USD",
|
|
168
|
+
) -> str:
|
|
169
|
+
await _ensure_connected()
|
|
170
|
+
contract = _create_contract(symbol, sec_type, exchange, currency)
|
|
171
|
+
try:
|
|
172
|
+
contracts_raw = await self.ib.qualifyContractsAsync(contract)
|
|
173
|
+
contracts = _flatten_contracts(contracts_raw)
|
|
174
|
+
if not contracts:
|
|
175
|
+
return f"No contract found for {symbol}"
|
|
176
|
+
conid = getattr(contracts[0], "conId", None)
|
|
177
|
+
result = [f"ConID for {symbol}: {conid}"]
|
|
178
|
+
if len(contracts) > 1:
|
|
179
|
+
result.append(
|
|
180
|
+
f"\nNote: Found {len(contracts)} contracts. Using first one."
|
|
181
|
+
)
|
|
182
|
+
result.append("All ConIDs found:")
|
|
183
|
+
for i, c in enumerate(contracts, 1):
|
|
184
|
+
if c is None:
|
|
185
|
+
continue
|
|
186
|
+
result.append(
|
|
187
|
+
f" {i}. {getattr(c, 'conId', '')} "
|
|
188
|
+
f"({getattr(c, 'exchange', '')}, {getattr(c, 'currency', '')})"
|
|
189
|
+
)
|
|
190
|
+
return "\n".join(result)
|
|
191
|
+
except Exception as e: # pragma: no cover
|
|
192
|
+
return f"Error converting ticker to conid: {e}"
|
|
193
|
+
|
|
194
|
+
@self.server.tool(description="Retrieve historical market data")
|
|
195
|
+
async def get_historical_data(
|
|
196
|
+
symbol: Annotated[str, "Stock symbol or conid"],
|
|
197
|
+
duration: Annotated[str, "Duration (e.g., '1 M', '1 Y', '5 D')"] = "1 M",
|
|
198
|
+
bar_size: Annotated[
|
|
199
|
+
str, "Bar size (e.g., '1 day', '1 hour', '5 mins')"
|
|
200
|
+
] = "1 day",
|
|
201
|
+
data_type: Annotated[
|
|
202
|
+
str,
|
|
203
|
+
"Data type (TRADES, MIDPOINT, BID, ASK, FEE_RATE, OPTION_IMPLIED_VOLATILITY)",
|
|
204
|
+
] = "TRADES",
|
|
205
|
+
max_bars: Annotated[
|
|
206
|
+
int,
|
|
207
|
+
Field(description="Maximum number of bars to retrieve", ge=1, le=500),
|
|
208
|
+
] = 20,
|
|
209
|
+
exchange: str = "SMART",
|
|
210
|
+
currency: str = "USD",
|
|
211
|
+
) -> str:
|
|
212
|
+
await _ensure_connected()
|
|
213
|
+
contract = _create_contract(symbol, "STK", exchange, currency)
|
|
214
|
+
try:
|
|
215
|
+
contracts_raw = await self.ib.qualifyContractsAsync(contract)
|
|
216
|
+
contracts = _flatten_contracts(contracts_raw)
|
|
217
|
+
if not contracts:
|
|
218
|
+
return f"No contract found for {symbol}"
|
|
219
|
+
c = contracts[0]
|
|
220
|
+
bars = await self.ib.reqHistoricalDataAsync(
|
|
221
|
+
contract=c,
|
|
222
|
+
endDateTime="",
|
|
223
|
+
durationStr=duration,
|
|
224
|
+
barSizeSetting=bar_size,
|
|
225
|
+
whatToShow=data_type,
|
|
226
|
+
useRTH=True,
|
|
227
|
+
)
|
|
228
|
+
if not bars:
|
|
229
|
+
return f"No historical data found for {symbol}"
|
|
230
|
+
header = [
|
|
231
|
+
f"Historical data for {symbol} ({getattr(c, 'conId', '')}):",
|
|
232
|
+
(
|
|
233
|
+
f"Duration: {duration}, Bar Size: {bar_size}, "
|
|
234
|
+
f"Data Type: {data_type}"
|
|
235
|
+
),
|
|
236
|
+
"",
|
|
237
|
+
(
|
|
238
|
+
f"{'Date':<12} {'Open':<10} {'High':<10} {'Low':<10} "
|
|
239
|
+
f"{'Close':<10} {'Volume':<12}"
|
|
240
|
+
),
|
|
241
|
+
"-" * 70,
|
|
242
|
+
]
|
|
243
|
+
lines = []
|
|
244
|
+
for bar in bars[-max_bars:]:
|
|
245
|
+
if hasattr(bar.date, "strftime"):
|
|
246
|
+
date_str = bar.date.strftime("%Y-%m-%d") # type: ignore[attr-defined]
|
|
247
|
+
else:
|
|
248
|
+
date_str = str(bar.date)
|
|
249
|
+
lines.append(
|
|
250
|
+
f"{date_str:<12} {bar.open:<10.2f} {bar.high:<10.2f} "
|
|
251
|
+
f"{bar.low:<10.2f} {bar.close:<10.2f} {bar.volume:<12}"
|
|
252
|
+
)
|
|
253
|
+
if len(bars) > max_bars:
|
|
254
|
+
lines.append(
|
|
255
|
+
f"\n... showing last {max_bars} of {len(bars)} total bars"
|
|
256
|
+
)
|
|
257
|
+
return "\n".join(header + lines)
|
|
258
|
+
except Exception as e: # pragma: no cover
|
|
259
|
+
return f"Error getting historical data: {e}"
|
|
260
|
+
|
|
261
|
+
@self.server.tool(
|
|
262
|
+
description="Search for contracts by partial symbol or company name"
|
|
263
|
+
)
|
|
264
|
+
async def search_contracts(
|
|
265
|
+
pattern: Annotated[str, "Search pattern (symbol or company name)"],
|
|
266
|
+
) -> str:
|
|
267
|
+
await _ensure_connected()
|
|
268
|
+
try:
|
|
269
|
+
results = await self.ib.reqMatchingSymbolsAsync(pattern)
|
|
270
|
+
if not results:
|
|
271
|
+
return f"No contracts found matching '{pattern}'"
|
|
272
|
+
lines = [f"Contracts matching '{pattern}':", ""]
|
|
273
|
+
for i, desc in enumerate(results[:10], 1):
|
|
274
|
+
c = desc.contract
|
|
275
|
+
if c is None:
|
|
276
|
+
continue
|
|
277
|
+
lines.extend(
|
|
278
|
+
[
|
|
279
|
+
f"{i}. {c.symbol} ({c.conId})",
|
|
280
|
+
f" - Security Type: {c.secType}",
|
|
281
|
+
f" - Exchange: {c.primaryExchange or c.exchange}",
|
|
282
|
+
f" - Currency: {c.currency}",
|
|
283
|
+
"",
|
|
284
|
+
]
|
|
285
|
+
)
|
|
286
|
+
if len(results) > 10:
|
|
287
|
+
lines.append(f"... and {len(results) - 10} more results")
|
|
288
|
+
return "\n".join(lines)
|
|
289
|
+
except Exception as e: # pragma: no cover
|
|
290
|
+
return f"Error searching contracts: {e}"
|
|
291
|
+
|
|
292
|
+
@self.server.tool(description="Retrieve historical news articles")
|
|
293
|
+
async def get_historical_news(
|
|
294
|
+
symbol: Annotated[str, "Stock symbol or conid"],
|
|
295
|
+
start_date: Annotated[str, "Start date (YYYY-MM-DD)"],
|
|
296
|
+
end_date: Annotated[str, "End date (YYYY-MM-DD)"],
|
|
297
|
+
max_count: Annotated[int, "Maximum number of articles to retrieve"] = 10,
|
|
298
|
+
exchange: Annotated[
|
|
299
|
+
str, "Exchange (e.g., SMART, NYSE, NASDAQ, etc.)"
|
|
300
|
+
] = "SMART",
|
|
301
|
+
currency: Annotated[str, "Currency (e.g., USD, EUR, etc.)"] = "USD",
|
|
302
|
+
) -> str:
|
|
303
|
+
await _ensure_connected()
|
|
304
|
+
contract = _create_contract(symbol, "STK", exchange, currency)
|
|
305
|
+
try:
|
|
306
|
+
contracts_raw = await self.ib.qualifyContractsAsync(contract)
|
|
307
|
+
contracts = _flatten_contracts(contracts_raw)
|
|
308
|
+
if not contracts:
|
|
309
|
+
return f"No contract found for {symbol}"
|
|
310
|
+
c = contracts[0]
|
|
311
|
+
news = await self.ib.reqHistoricalNewsAsync(
|
|
312
|
+
getattr(c, "conId", 0),
|
|
313
|
+
self.news_provider_codes,
|
|
314
|
+
start_date,
|
|
315
|
+
end_date,
|
|
316
|
+
max_count,
|
|
317
|
+
)
|
|
318
|
+
if not news:
|
|
319
|
+
return f"No historical news found for {symbol}"
|
|
320
|
+
lines = [
|
|
321
|
+
f"Historical news for {symbol} ({getattr(c, 'conId', '')}):",
|
|
322
|
+
f"Period: {start_date} to {end_date}",
|
|
323
|
+
"",
|
|
324
|
+
]
|
|
325
|
+
if isinstance(news, list):
|
|
326
|
+
for i, article in enumerate(news[:max_count], 1):
|
|
327
|
+
lines.extend(
|
|
328
|
+
[
|
|
329
|
+
f"{i}. {getattr(article, 'headline', '')}",
|
|
330
|
+
f" Time: {getattr(article, 'time', '')}",
|
|
331
|
+
f" Provider: {getattr(article, 'providerCode', '')}",
|
|
332
|
+
f" Article ID: {getattr(article, 'articleId', '')}",
|
|
333
|
+
"",
|
|
334
|
+
]
|
|
335
|
+
)
|
|
336
|
+
return "\n".join(lines)
|
|
337
|
+
except Exception as e: # pragma: no cover
|
|
338
|
+
return f"Error getting historical news: {e}"
|
|
339
|
+
|
|
340
|
+
@self.server.tool(
|
|
341
|
+
description="Retrieve a full news article by ID and provider code"
|
|
342
|
+
)
|
|
343
|
+
async def get_article(
|
|
344
|
+
articleId: Annotated[str, "Article ID returned from historical news"],
|
|
345
|
+
providerCode: Annotated[str, "Provider code returned from historical news"],
|
|
346
|
+
as_markdown: Annotated[
|
|
347
|
+
bool,
|
|
348
|
+
"Attempt to convert XML content to markdown if the article is XML",
|
|
349
|
+
] = True,
|
|
350
|
+
truncate: Annotated[
|
|
351
|
+
int,
|
|
352
|
+
"Optional max length of returned text (0 for no truncation)",
|
|
353
|
+
] = 0,
|
|
354
|
+
) -> str:
|
|
355
|
+
await _ensure_connected()
|
|
356
|
+
try:
|
|
357
|
+
# Prefer async variant if available
|
|
358
|
+
if hasattr(self.ib, "reqNewsArticleAsync"):
|
|
359
|
+
article_obj = await self.ib.reqNewsArticleAsync(providerCode, articleId) # type: ignore[attr-defined]
|
|
360
|
+
else: # pragma: no cover - fallback path
|
|
361
|
+
article_obj = self.ib.reqNewsArticle(providerCode, articleId) # type: ignore[attr-defined]
|
|
362
|
+
if article_obj is None:
|
|
363
|
+
return f"No article content found for {providerCode}:{articleId}"
|
|
364
|
+
raw_text = getattr(article_obj, "articleText", "") or getattr(
|
|
365
|
+
article_obj, "text", ""
|
|
366
|
+
)
|
|
367
|
+
if not raw_text:
|
|
368
|
+
return f"Article {providerCode}:{articleId} has no text content"
|
|
369
|
+
if as_markdown:
|
|
370
|
+
formatted = _xml_to_markdown(raw_text)
|
|
371
|
+
else:
|
|
372
|
+
formatted = raw_text
|
|
373
|
+
if truncate and truncate > 0 and len(formatted) > truncate:
|
|
374
|
+
formatted = formatted[:truncate].rstrip() + "... (truncated)"
|
|
375
|
+
return "\n".join(
|
|
376
|
+
[
|
|
377
|
+
f"Article {articleId} ({providerCode}):",
|
|
378
|
+
"",
|
|
379
|
+
formatted,
|
|
380
|
+
]
|
|
381
|
+
)
|
|
382
|
+
except Exception as e: # pragma: no cover
|
|
383
|
+
return f"Error retrieving article {providerCode}:{articleId}: {e}"
|
|
384
|
+
|
|
385
|
+
@self.server.tool(description="Retrieve fundamental data for a contract")
|
|
386
|
+
async def get_fundamental_data(
|
|
387
|
+
symbol: Annotated[str, "Stock symbol or conid"],
|
|
388
|
+
report_type: Annotated[
|
|
389
|
+
str,
|
|
390
|
+
(
|
|
391
|
+
"Report type (ReportsFinSummary, ReportsOwnership, "
|
|
392
|
+
"ReportsFinStatements, RESC, CalendarReport)"
|
|
393
|
+
),
|
|
394
|
+
] = "ReportsFinSummary",
|
|
395
|
+
exchange: str = "SMART",
|
|
396
|
+
currency: str = "USD",
|
|
397
|
+
) -> str:
|
|
398
|
+
await _ensure_connected()
|
|
399
|
+
contract = _create_contract(symbol, "STK", exchange, currency)
|
|
400
|
+
try:
|
|
401
|
+
contracts_raw = await self.ib.qualifyContractsAsync(contract)
|
|
402
|
+
contracts = _flatten_contracts(contracts_raw)
|
|
403
|
+
if not contracts:
|
|
404
|
+
return f"No contract found for {symbol}"
|
|
405
|
+
c = contracts[0]
|
|
406
|
+
data = await self.ib.reqFundamentalDataAsync(c, report_type)
|
|
407
|
+
if not data:
|
|
408
|
+
return f"No fundamental data found for {symbol}"
|
|
409
|
+
formatted = _xml_to_markdown(data)
|
|
410
|
+
lines = [
|
|
411
|
+
f"Fundamental data for {symbol} ({getattr(c, 'conId', '')}):",
|
|
412
|
+
f"Report Type: {report_type}",
|
|
413
|
+
"",
|
|
414
|
+
formatted,
|
|
415
|
+
]
|
|
416
|
+
return "\n".join(lines)
|
|
417
|
+
except Exception as e: # pragma: no cover
|
|
418
|
+
return f"Error getting fundamental data: {e}"
|
|
419
|
+
|
|
420
|
+
@self.server.tool(description="Retrieve portfolio positions and details")
|
|
421
|
+
async def get_portfolio(
|
|
422
|
+
account: Annotated[str, "Account name (empty for all accounts)"] = "",
|
|
423
|
+
) -> str:
|
|
424
|
+
await _ensure_connected()
|
|
425
|
+
try:
|
|
426
|
+
items = self.ib.portfolio(account)
|
|
427
|
+
if not items:
|
|
428
|
+
return "No portfolio items found"
|
|
429
|
+
head = [
|
|
430
|
+
f"Portfolio {'for account ' + account if account else '(all accounts)'}:",
|
|
431
|
+
"",
|
|
432
|
+
(
|
|
433
|
+
f"{'Symbol':<10} {'Position':<10} {'Avg Cost':<12} "
|
|
434
|
+
f"{'Market Value':<15} {'Unrealized PnL':<15}"
|
|
435
|
+
),
|
|
436
|
+
"-" * 70,
|
|
437
|
+
]
|
|
438
|
+
rows = [
|
|
439
|
+
f"{it.contract.symbol:<10} {it.position:<10} {it.averageCost:<12.2f} "
|
|
440
|
+
f"{it.marketValue:<15.2f} {it.unrealizedPNL:<15.2f}"
|
|
441
|
+
for it in items
|
|
442
|
+
]
|
|
443
|
+
return "\n".join(head + rows)
|
|
444
|
+
except Exception as e: # pragma: no cover
|
|
445
|
+
return f"Error getting portfolio: {e}"
|
|
446
|
+
|
|
447
|
+
@self.server.tool(description="Retrieve account summary information")
|
|
448
|
+
async def get_account_summary(
|
|
449
|
+
account: Annotated[str, "Account name (empty for all accounts)"] = "",
|
|
450
|
+
) -> str:
|
|
451
|
+
await _ensure_connected()
|
|
452
|
+
try:
|
|
453
|
+
vals = await self.ib.accountSummaryAsync(account)
|
|
454
|
+
if not vals:
|
|
455
|
+
return "No account data found"
|
|
456
|
+
by_acc: dict[str, list[Any]] = {}
|
|
457
|
+
for v in vals:
|
|
458
|
+
by_acc.setdefault(v.account, []).append(v)
|
|
459
|
+
lines: list[str] = [
|
|
460
|
+
f"Account Summary {'for ' + account if account else '(all accounts)'}:",
|
|
461
|
+
"",
|
|
462
|
+
]
|
|
463
|
+
for acc, values in by_acc.items():
|
|
464
|
+
lines.append(f"Account: {acc}")
|
|
465
|
+
lines.append("-" * 40)
|
|
466
|
+
for v in values:
|
|
467
|
+
lines.append(f"{v.tag}: {v.value} {v.currency}")
|
|
468
|
+
lines.append("")
|
|
469
|
+
return "\n".join(lines)
|
|
470
|
+
except Exception as e: # pragma: no cover
|
|
471
|
+
return f"Error getting account summary: {e}"
|
|
472
|
+
|
|
473
|
+
@self.server.tool(description="Retrieve current positions")
|
|
474
|
+
async def get_positions(
|
|
475
|
+
account: Annotated[str, "Account name (empty for all accounts)"] = "",
|
|
476
|
+
) -> str:
|
|
477
|
+
await _ensure_connected()
|
|
478
|
+
try:
|
|
479
|
+
positions = self.ib.positions(account)
|
|
480
|
+
if not positions:
|
|
481
|
+
return "No positions found"
|
|
482
|
+
head = [
|
|
483
|
+
f"Positions {'for account ' + account if account else '(all accounts)'}:",
|
|
484
|
+
"",
|
|
485
|
+
f"{'Account':<10} {'Symbol':<10} {'Position':<10} {'Avg Cost':<12}",
|
|
486
|
+
"-" * 50,
|
|
487
|
+
]
|
|
488
|
+
rows = [
|
|
489
|
+
f"{p.account:<10} {p.contract.symbol:<10} {p.position:<10} {p.avgCost:<12.2f}"
|
|
490
|
+
for p in positions
|
|
491
|
+
]
|
|
492
|
+
return "\n".join(head + rows)
|
|
493
|
+
except Exception as e: # pragma: no cover
|
|
494
|
+
return f"Error getting positions: {e}"
|
|
495
|
+
|
|
496
|
+
@self.server.tool(
|
|
497
|
+
description=(
|
|
498
|
+
"Get detailed contract information including dividends and corporate actions"
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
async def get_contract_details(
|
|
502
|
+
symbol: Annotated[str, "Stock symbol or conid"],
|
|
503
|
+
sec_type: str = "STK",
|
|
504
|
+
exchange: str = "SMART",
|
|
505
|
+
currency: str = "USD",
|
|
506
|
+
) -> str:
|
|
507
|
+
await _ensure_connected()
|
|
508
|
+
contract = _create_contract(symbol, sec_type, exchange, currency)
|
|
509
|
+
try:
|
|
510
|
+
contracts_raw = await self.ib.qualifyContractsAsync(contract)
|
|
511
|
+
contracts = _flatten_contracts(contracts_raw)
|
|
512
|
+
if not contracts:
|
|
513
|
+
return f"No contract found for {symbol}"
|
|
514
|
+
c = contracts[0]
|
|
515
|
+
details_list = await self.ib.reqContractDetailsAsync(c)
|
|
516
|
+
if not details_list:
|
|
517
|
+
return f"No contract details found for {symbol}"
|
|
518
|
+
d = details_list[0]
|
|
519
|
+
lines = [
|
|
520
|
+
f"Contract Details for {symbol} ({getattr(c, 'conId', '')}):",
|
|
521
|
+
"",
|
|
522
|
+
]
|
|
523
|
+
lines.extend(
|
|
524
|
+
[
|
|
525
|
+
"**Basic Information:**",
|
|
526
|
+
f"- Long Name: {getattr(d, 'longName', '')}",
|
|
527
|
+
f"- Industry: {getattr(d, 'industry', '')}",
|
|
528
|
+
f"- Category: {getattr(d, 'category', '')}",
|
|
529
|
+
f"- Subcategory: {getattr(d, 'subcategory', '')}",
|
|
530
|
+
f"- Market Name: {getattr(d, 'marketName', '')}",
|
|
531
|
+
f"- Trading Hours: {getattr(d, 'tradingHours', '')}",
|
|
532
|
+
f"- Liquid Hours: {getattr(d, 'liquidHours', '')}",
|
|
533
|
+
"",
|
|
534
|
+
"**Financial Information:**",
|
|
535
|
+
f"- Min Tick: {getattr(d, 'minTick', '')}",
|
|
536
|
+
f"- Price Magnifier: {getattr(d, 'priceMagnifier', '')}",
|
|
537
|
+
f"- Market Cap: {getattr(d, 'marketCap', 'N/A')}",
|
|
538
|
+
f"- Shares Outstanding: {getattr(d, 'sharesOutstanding', 'N/A')}",
|
|
539
|
+
]
|
|
540
|
+
)
|
|
541
|
+
# Dividends if available
|
|
542
|
+
dividends = getattr(d, "dividends", None)
|
|
543
|
+
if dividends:
|
|
544
|
+
lines.append("")
|
|
545
|
+
lines.append("**Recent Dividends:**")
|
|
546
|
+
for div in dividends[:5]:
|
|
547
|
+
if div is not None:
|
|
548
|
+
lines.append(
|
|
549
|
+
f"- {getattr(div, 'date', '')}: ${getattr(div, 'amount', '')} "
|
|
550
|
+
f"({getattr(div, 'currency', '')})"
|
|
551
|
+
)
|
|
552
|
+
return "\n".join(lines)
|
|
553
|
+
except Exception as e: # pragma: no cover
|
|
554
|
+
return f"Error getting contract details: {e}"
|
|
555
|
+
|
|
556
|
+
# Keep references on self to make tools reachable in tests/REPL if needed
|
|
557
|
+
self.lookup_contract = lookup_contract # type: ignore[attr-defined]
|
|
558
|
+
self.ticker_to_conid = ticker_to_conid # type: ignore[attr-defined]
|
|
559
|
+
self.get_historical_data = get_historical_data # type: ignore[attr-defined]
|
|
560
|
+
self.search_contracts = search_contracts # type: ignore[attr-defined]
|
|
561
|
+
self.get_historical_news = get_historical_news # type: ignore[attr-defined]
|
|
562
|
+
self.get_fundamental_data = get_fundamental_data # type: ignore[attr-defined]
|
|
563
|
+
self.get_portfolio = get_portfolio # type: ignore[attr-defined]
|
|
564
|
+
self.get_account_summary = get_account_summary # type: ignore[attr-defined]
|
|
565
|
+
self.get_positions = get_positions # type: ignore[attr-defined]
|
|
566
|
+
self.get_contract_details = get_contract_details # type: ignore[attr-defined]
|
|
567
|
+
|
|
568
|
+
def run(self) -> None:
|
|
569
|
+
"""Run the FastMCP stdio server (synchronous)."""
|
|
570
|
+
logging.basicConfig(level=logging.INFO)
|
|
571
|
+
try:
|
|
572
|
+
# FastMCP's run manages its own event loop using anyio.run internally.
|
|
573
|
+
self.server.run()
|
|
574
|
+
finally: # pragma: no cover - disconnect path is runtime-only
|
|
575
|
+
if self.connected:
|
|
576
|
+
self.ib.disconnect()
|
|
577
|
+
self.connected = False
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def main() -> None:
|
|
581
|
+
"""CLI entry point for running the server over stdio."""
|
|
582
|
+
import argparse
|
|
583
|
+
import os
|
|
584
|
+
|
|
585
|
+
parser = argparse.ArgumentParser(
|
|
586
|
+
description="Interactive Brokers MCP Server (FastMCP)"
|
|
587
|
+
)
|
|
588
|
+
parser.add_argument(
|
|
589
|
+
"--host",
|
|
590
|
+
default=os.getenv("IB_HOST", "127.0.0.1"),
|
|
591
|
+
help="IB Gateway/TWS host (env: IB_HOST)",
|
|
592
|
+
)
|
|
593
|
+
parser.add_argument(
|
|
594
|
+
"--port",
|
|
595
|
+
type=int,
|
|
596
|
+
default=int(os.getenv("IB_PORT", "7497")),
|
|
597
|
+
help="IB Gateway/TWS port (env: IB_PORT)",
|
|
598
|
+
)
|
|
599
|
+
parser.add_argument(
|
|
600
|
+
"--client-id",
|
|
601
|
+
type=int,
|
|
602
|
+
default=int(os.getenv("IB_CLIENT_ID", "1")),
|
|
603
|
+
help="Client ID (env: IB_CLIENT_ID)",
|
|
604
|
+
)
|
|
605
|
+
args = parser.parse_args()
|
|
606
|
+
|
|
607
|
+
server = IBMCPServer(args.host, args.port, args.client_id)
|
|
608
|
+
server.run()
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
__all__ = ["IBMCPServer", "main"]
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
if __name__ == "__main__":
|
|
615
|
+
main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "ib-mcp"
|
|
3
|
+
version = "0.2.5"
|
|
4
|
+
description = "Model Context Protocol (MCP) server exposing Interactive Brokers data via ib_async + FastMCP"
|
|
5
|
+
authors = ["David Hellekalek <hellekalek@gmail.com>"]
|
|
6
|
+
license = "BSD-3-Clause"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
homepage = "https://github.com/Hellek1/ib-mcp"
|
|
9
|
+
repository = "https://github.com/Hellek1/ib-mcp"
|
|
10
|
+
documentation = "https://github.com/Hellek1/ib-mcp#readme"
|
|
11
|
+
keywords = ["interactive-brokers", "ib", "ibapi", "tws", "asyncio", "mcp", "fastmcp", "llm"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
16
|
+
"License :: OSI Approved :: BSD License",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
# Use flat layout (package lives at project root)
|
|
24
|
+
packages = [ { include = "ib_mcp" } ]
|
|
25
|
+
|
|
26
|
+
# Include type information marker
|
|
27
|
+
include = ["ib_mcp/py.typed"]
|
|
28
|
+
|
|
29
|
+
[tool.poetry.scripts]
|
|
30
|
+
ib-mcp-server = "ib_mcp.server:main"
|
|
31
|
+
|
|
32
|
+
[tool.poetry.dependencies]
|
|
33
|
+
python = ">=3.12,<4.0"
|
|
34
|
+
# Core runtime deps
|
|
35
|
+
ib_async = "^2.0.1"
|
|
36
|
+
fastmcp = ">=2.11.0"
|
|
37
|
+
# Low-level MCP protocol lib (kept separate; fastmcp currently builds on it)
|
|
38
|
+
defusedxml = ">=0.7.1"
|
|
39
|
+
|
|
40
|
+
# (Optional) pin pydantic major for stability if fastmcp upgrades; uncomment if needed
|
|
41
|
+
# pydantic = "^2.8"
|
|
42
|
+
|
|
43
|
+
[tool.poetry.group.dev.dependencies]
|
|
44
|
+
pytest = ">=8.0"
|
|
45
|
+
pytest-asyncio = ">=0.23"
|
|
46
|
+
mypy = ">=1.11.0"
|
|
47
|
+
ruff = ">=0.5.0"
|
|
48
|
+
pre-commit = ">=3.8.0"
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
ignore_missing_imports = true
|
|
52
|
+
check_untyped_defs = true
|
|
53
|
+
|
|
54
|
+
[tool.ruff]
|
|
55
|
+
line-length = 100
|
|
56
|
+
target-version = "py312"
|
|
57
|
+
|
|
58
|
+
[tool.ruff.lint]
|
|
59
|
+
select = ["E", "F", "I", "B", "UP", "ANN", "S", "ISC", "W"]
|
|
60
|
+
ignore = []
|
|
61
|
+
|
|
62
|
+
[tool.ruff.format]
|
|
63
|
+
quote-style = "double"
|
|
64
|
+
indent-style = "space"
|
|
65
|
+
skip-magic-trailing-comma = false
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
asyncio_mode = "auto"
|
|
69
|
+
pythonpath = ["."]
|
|
70
|
+
|
|
71
|
+
[build-system]
|
|
72
|
+
requires = ["poetry-core>=1.9.0"]
|
|
73
|
+
build-backend = "poetry.core.masonry.api"
|