groww-mcp 1.0.0
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.
- checksums.yaml +7 -0
- data/.env.example +19 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/README.md +308 -0
- data/bin/groww-mcp +46 -0
- data/lib/groww_mcp/auth.rb +118 -0
- data/lib/groww_mcp/base_tool.rb +22 -0
- data/lib/groww_mcp/client.rb +356 -0
- data/lib/groww_mcp/tools/auth_tools.rb +42 -0
- data/lib/groww_mcp/tools/instrument_tools.rb +112 -0
- data/lib/groww_mcp/tools/market_tools.rb +207 -0
- data/lib/groww_mcp/tools/option_chain_tools.rb +125 -0
- data/lib/groww_mcp/tools/order_tools.rb +211 -0
- data/lib/groww_mcp/tools/portfolio_tools.rb +109 -0
- data/lib/groww_mcp/tools/smart_order_tools.rb +227 -0
- data/lib/groww_mcp/tools/user_tools.rb +24 -0
- data/lib/groww_mcp/version.rb +5 -0
- data/lib/groww_mcp.rb +51 -0
- metadata +92 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "csv"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
9
|
+
module GrowwMcp
|
|
10
|
+
class Client
|
|
11
|
+
BASE_URL = "https://api.groww.in"
|
|
12
|
+
MAX_RETRIES = 3
|
|
13
|
+
RETRY_DELAY = 0.5
|
|
14
|
+
INSTRUMENTS_CACHE_PATH = File.join(Dir.tmpdir, "groww-mcp-instruments.csv")
|
|
15
|
+
INSTRUMENTS_CACHE_TTL = 24 * 60 * 60 # 24 hours
|
|
16
|
+
|
|
17
|
+
def initialize(auth)
|
|
18
|
+
@auth = auth
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# === Portfolio ===
|
|
22
|
+
|
|
23
|
+
def holdings
|
|
24
|
+
get("/v1/holdings/user")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def positions(segment: nil)
|
|
28
|
+
path = "/v1/positions/user"
|
|
29
|
+
path += "?segment=#{segment}" if segment
|
|
30
|
+
get(path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def margins
|
|
34
|
+
get("/v1/margins/detail/user")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# === Orders ===
|
|
38
|
+
|
|
39
|
+
def order_list(segment: nil, page: nil, page_size: nil)
|
|
40
|
+
params = {}
|
|
41
|
+
params[:segment] = segment if segment
|
|
42
|
+
params[:page] = page if page
|
|
43
|
+
params[:page_size] = page_size if page_size
|
|
44
|
+
get("/v1/order/list", params)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def order_detail(order_id, segment: "CASH")
|
|
48
|
+
get("/v1/order/detail/#{order_id}", segment: segment)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def place_order(order)
|
|
52
|
+
# reference_id lets Groww dedupe if the same order is ever re-submitted
|
|
53
|
+
order = { order_reference_id: generate_reference_id }.merge(order)
|
|
54
|
+
post("/v1/order/create", order)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def modify_order(order_id, modifications)
|
|
58
|
+
post("/v1/order/modify", modifications.merge(groww_order_id: order_id))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cancel_order(order_id, segment: "CASH")
|
|
62
|
+
post("/v1/order/cancel", groww_order_id: order_id, segment: segment)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def order_trades(order_id, segment: "CASH")
|
|
66
|
+
get("/v1/order/trades/#{order_id}", segment: segment)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# === Smart Orders (GTT/OCO) ===
|
|
70
|
+
|
|
71
|
+
def smart_order_list(smart_order_type: nil, page: nil, page_size: nil)
|
|
72
|
+
params = {}
|
|
73
|
+
params[:smart_order_type] = smart_order_type if smart_order_type
|
|
74
|
+
params[:page] = page if page
|
|
75
|
+
params[:page_size] = page_size if page_size
|
|
76
|
+
get("/v1/order-advance/list", params)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def create_smart_order(order)
|
|
80
|
+
order = { reference_id: generate_reference_id }.merge(order)
|
|
81
|
+
post("/v1/order-advance/create", order)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def modify_smart_order(smart_order_id, smart_order_type:, segment:, modifications: {})
|
|
85
|
+
put("/v1/order-advance/modify/#{smart_order_id}",
|
|
86
|
+
modifications.merge(smart_order_type: smart_order_type, segment: segment))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def cancel_smart_order(smart_order_id, smart_order_type:, segment: "CASH")
|
|
90
|
+
post("/v1/order-advance/cancel/#{segment}/#{smart_order_type}/#{smart_order_id}", {})
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# === Live Data (requires ₹499/mo subscription) ===
|
|
94
|
+
|
|
95
|
+
def quote(trading_symbol, exchange: "NSE", segment: "CASH")
|
|
96
|
+
get("/v1/live-data/quote", trading_symbol: trading_symbol, exchange: exchange, segment: segment)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def ltp(symbols, exchange: "NSE", segment: "CASH")
|
|
100
|
+
get("/v1/live-data/ltp", segment: segment, exchange_symbols: exchange_symbols(symbols, exchange))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def ohlc(symbols, exchange: "NSE", segment: "CASH")
|
|
104
|
+
get("/v1/live-data/ohlc", segment: segment, exchange_symbols: exchange_symbols(symbols, exchange))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# === Option Chain (requires ₹499/mo subscription) ===
|
|
108
|
+
|
|
109
|
+
def option_chain(underlying, expiry_date:, exchange: "NSE")
|
|
110
|
+
get("/v1/option-chain/exchange/#{exchange}/underlying/#{underlying}", expiry_date: expiry_date)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# === Greeks (requires ₹499/mo subscription) ===
|
|
114
|
+
|
|
115
|
+
def greeks(trading_symbol:, underlying:, expiry:, exchange: "NSE")
|
|
116
|
+
get("/v1/live-data/greeks/exchange/#{exchange}/underlying/#{underlying}" \
|
|
117
|
+
"/trading_symbol/#{trading_symbol}/expiry/#{expiry}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# === Historical Data (requires ₹499/mo subscription) ===
|
|
121
|
+
|
|
122
|
+
def historical_candles(trading_symbol, start_time, end_time, exchange: "NSE", segment: "CASH",
|
|
123
|
+
interval_in_minutes: nil)
|
|
124
|
+
params = {
|
|
125
|
+
trading_symbol: trading_symbol, exchange: exchange, segment: segment,
|
|
126
|
+
start_time: normalize_time(start_time), end_time: normalize_time(end_time),
|
|
127
|
+
}
|
|
128
|
+
params[:interval_in_minutes] = interval_in_minutes if interval_in_minutes
|
|
129
|
+
get("/v1/historical/candle/range", params)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# === Instruments (local — resolved from Groww's public instruments CSV) ===
|
|
133
|
+
|
|
134
|
+
# Case-insensitive substring search on trading symbol and name.
|
|
135
|
+
# Runs entirely locally against the cached instruments CSV — no API call,
|
|
136
|
+
# no Live Data subscription needed.
|
|
137
|
+
def search_instruments(query, limit: 20)
|
|
138
|
+
q = query.to_s.downcase
|
|
139
|
+
return [] if q.empty?
|
|
140
|
+
|
|
141
|
+
matches = []
|
|
142
|
+
instruments.each do |row|
|
|
143
|
+
symbol = row["trading_symbol"].to_s.downcase
|
|
144
|
+
name = row["name"].to_s.downcase
|
|
145
|
+
matches << row if symbol.include?(q) || name.include?(q)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Rank so the most useful rows surface first: exact symbol match,
|
|
149
|
+
# then CASH (equity) before FNO contracts, then symbol-prefix matches.
|
|
150
|
+
matches.sort_by! do |row|
|
|
151
|
+
symbol = row["trading_symbol"].to_s.downcase
|
|
152
|
+
[symbol == q ? 0 : 1,
|
|
153
|
+
row["segment"] == "CASH" ? 0 : 1,
|
|
154
|
+
symbol.start_with?(q) ? 0 : 1,
|
|
155
|
+
symbol]
|
|
156
|
+
end
|
|
157
|
+
matches.first(limit).map(&:to_h)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Exact trading-symbol match (case-insensitive), filtered by exchange/segment.
|
|
161
|
+
# Returns the matching row as a Hash, or nil if not found.
|
|
162
|
+
def instrument_detail(trading_symbol, exchange: "NSE", segment: "CASH")
|
|
163
|
+
instruments.each do |row|
|
|
164
|
+
next unless row["trading_symbol"].to_s.casecmp?(trading_symbol.to_s)
|
|
165
|
+
next if exchange && !row["exchange"].to_s.empty? && !row["exchange"].casecmp?(exchange.to_s)
|
|
166
|
+
next if segment && !row["segment"].to_s.empty? && !row["segment"].casecmp?(segment.to_s)
|
|
167
|
+
|
|
168
|
+
return row.to_h
|
|
169
|
+
end
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def download_instruments
|
|
174
|
+
uri = URI("https://growwapi-assets.groww.in/instruments/instrument.csv")
|
|
175
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
176
|
+
http.use_ssl = true
|
|
177
|
+
response = http.request(Net::HTTP::Get.new(uri))
|
|
178
|
+
response.body
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# === User ===
|
|
182
|
+
|
|
183
|
+
def profile
|
|
184
|
+
get("/v1/user/detail")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# === Margin ===
|
|
188
|
+
|
|
189
|
+
# Margin requirement for a basket of orders.
|
|
190
|
+
# Body is a JSON ARRAY of order objects; segment goes as a query param.
|
|
191
|
+
def calculate_margin(orders, segment:)
|
|
192
|
+
post("/v1/margins/detail/orders", orders, params: { segment: segment })
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# Build the comma-separated "EXCHANGE_SYMBOL" pairs the live-data API expects,
|
|
198
|
+
# e.g. ["RELIANCE", "TCS"] + NSE => "NSE_RELIANCE,NSE_TCS".
|
|
199
|
+
# Symbols already containing "_" are passed through untouched.
|
|
200
|
+
def exchange_symbols(symbols, exchange)
|
|
201
|
+
Array(symbols).map { |s| s.include?("_") ? s : "#{exchange}_#{s}" }.join(",")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Historical API accepts epoch MILLISECONDS or "yyyy-MM-dd HH:mm:ss" strings.
|
|
205
|
+
# Strings pass through; numbers that look like epoch seconds get converted to ms.
|
|
206
|
+
def normalize_time(value)
|
|
207
|
+
return value if value.is_a?(String)
|
|
208
|
+
|
|
209
|
+
n = value.to_i
|
|
210
|
+
n < 1_000_000_000_000 ? n * 1000 : n
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Cached, parsed instruments CSV (24h TTL, stored in the system tmp dir).
|
|
214
|
+
def instruments
|
|
215
|
+
if @instruments.nil? || (Time.now - @instruments_loaded_at) > INSTRUMENTS_CACHE_TTL
|
|
216
|
+
@instruments = CSV.parse(instruments_csv, headers: true)
|
|
217
|
+
@instruments_loaded_at = Time.now
|
|
218
|
+
end
|
|
219
|
+
@instruments
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def instruments_csv
|
|
223
|
+
cache = INSTRUMENTS_CACHE_PATH
|
|
224
|
+
if File.exist?(cache) && (Time.now - File.mtime(cache)) < INSTRUMENTS_CACHE_TTL
|
|
225
|
+
File.read(cache)
|
|
226
|
+
else
|
|
227
|
+
data = download_instruments
|
|
228
|
+
File.write(cache, data)
|
|
229
|
+
data
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def get(path, params = {})
|
|
234
|
+
token = @auth.ensure_token!
|
|
235
|
+
|
|
236
|
+
uri = URI("#{BASE_URL}#{path}")
|
|
237
|
+
unless params.empty? || path.include?("?")
|
|
238
|
+
uri.query = URI.encode_www_form(params)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
request = Net::HTTP::Get.new(uri)
|
|
242
|
+
request["Authorization"] = "Bearer #{token}"
|
|
243
|
+
request["Accept"] = "application/json"
|
|
244
|
+
request["X-API-VERSION"] = "1.0"
|
|
245
|
+
|
|
246
|
+
execute(uri, request)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def post(path, body, params: {})
|
|
250
|
+
token = @auth.ensure_token!
|
|
251
|
+
|
|
252
|
+
uri = URI("#{BASE_URL}#{path}")
|
|
253
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
254
|
+
request = Net::HTTP::Post.new(uri)
|
|
255
|
+
request["Authorization"] = "Bearer #{token}"
|
|
256
|
+
request["Content-Type"] = "application/json"
|
|
257
|
+
request["Accept"] = "application/json"
|
|
258
|
+
request["X-API-VERSION"] = "1.0"
|
|
259
|
+
request.body = body.to_json
|
|
260
|
+
|
|
261
|
+
execute(uri, request, idempotent: false)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def put(path, body)
|
|
265
|
+
token = @auth.ensure_token!
|
|
266
|
+
|
|
267
|
+
uri = URI("#{BASE_URL}#{path}")
|
|
268
|
+
request = Net::HTTP::Put.new(uri)
|
|
269
|
+
request["Authorization"] = "Bearer #{token}"
|
|
270
|
+
request["Content-Type"] = "application/json"
|
|
271
|
+
request["Accept"] = "application/json"
|
|
272
|
+
request["X-API-VERSION"] = "1.0"
|
|
273
|
+
request.body = body.to_json
|
|
274
|
+
|
|
275
|
+
execute(uri, request, idempotent: false)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def generate_reference_id
|
|
279
|
+
rand(10_000_000..99_999_999).to_s
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def execute(uri, request, idempotent: true)
|
|
283
|
+
retries = 0
|
|
284
|
+
auth_retried = false
|
|
285
|
+
|
|
286
|
+
begin
|
|
287
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
288
|
+
http.use_ssl = true
|
|
289
|
+
http.open_timeout = 10
|
|
290
|
+
http.read_timeout = 15
|
|
291
|
+
|
|
292
|
+
response = http.request(request)
|
|
293
|
+
|
|
294
|
+
case response
|
|
295
|
+
when Net::HTTPSuccess
|
|
296
|
+
JSON.parse(response.body)
|
|
297
|
+
when Net::HTTPUnauthorized
|
|
298
|
+
if auth_retried
|
|
299
|
+
raise ApiError, "Groww API error (401): token expired — Groww tokens expire daily at " \
|
|
300
|
+
"6 AM IST; update GROWW_ACCESS_TOKEN or switch to TOTP auth " \
|
|
301
|
+
"(GROWW_TOTP_KEY + GROWW_TOTP_SECRET) for automatic refresh."
|
|
302
|
+
end
|
|
303
|
+
raise StaleTokenRetry
|
|
304
|
+
when Net::HTTPTooManyRequests
|
|
305
|
+
raise RateLimitError, "Rate limited. Try again later."
|
|
306
|
+
when Net::HTTPForbidden
|
|
307
|
+
raise ForbiddenError, "Access forbidden. This endpoint may require the Groww Live Data subscription (₹499/mo)."
|
|
308
|
+
else
|
|
309
|
+
error_body = JSON.parse(response.body) rescue {}
|
|
310
|
+
message = error_body.dig("error", "message") ||
|
|
311
|
+
error_body.dig("error", "errorMessage") ||
|
|
312
|
+
response.body
|
|
313
|
+
raise ApiError, "Groww API error (#{response.code}): #{message}"
|
|
314
|
+
end
|
|
315
|
+
rescue StaleTokenRetry
|
|
316
|
+
# 401 — the cached token is stale (Groww tokens expire daily at 6 AM IST).
|
|
317
|
+
# Invalidate it, fetch a fresh one, update the Authorization header on the
|
|
318
|
+
# already-built request, and retry exactly once.
|
|
319
|
+
auth_retried = true
|
|
320
|
+
@auth.invalidate!
|
|
321
|
+
request["Authorization"] = "Bearer #{@auth.ensure_token!}"
|
|
322
|
+
retry
|
|
323
|
+
rescue Net::OpenTimeout => e
|
|
324
|
+
# Connection was never established, so the request was not sent — safe to retry.
|
|
325
|
+
retries += 1
|
|
326
|
+
if retries <= MAX_RETRIES
|
|
327
|
+
sleep(RETRY_DELAY * retries)
|
|
328
|
+
retry
|
|
329
|
+
end
|
|
330
|
+
raise ApiError, "Network error after #{MAX_RETRIES} retries: #{e.message}"
|
|
331
|
+
rescue Net::ReadTimeout, Errno::ECONNRESET => e
|
|
332
|
+
# The request may have already reached Groww. Retrying a non-idempotent
|
|
333
|
+
# request (order create/modify/cancel) could execute it twice.
|
|
334
|
+
if idempotent
|
|
335
|
+
retries += 1
|
|
336
|
+
if retries <= MAX_RETRIES
|
|
337
|
+
sleep(RETRY_DELAY * retries)
|
|
338
|
+
retry
|
|
339
|
+
end
|
|
340
|
+
raise ApiError, "Network error after #{MAX_RETRIES} retries: #{e.message}"
|
|
341
|
+
end
|
|
342
|
+
raise ApiError, "Network error (#{e.class}): #{e.message}. NOT retried because the request " \
|
|
343
|
+
"may have already been accepted by Groww — check order status " \
|
|
344
|
+
"(get_orders / get_smart_orders) before submitting again."
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
class ApiError < StandardError; end
|
|
350
|
+
class RateLimitError < ApiError; end
|
|
351
|
+
class ForbiddenError < ApiError; end
|
|
352
|
+
|
|
353
|
+
# Internal sentinel: raised on the first 401 so Client#execute can refresh
|
|
354
|
+
# the token and retry the request once. Never propagates to callers.
|
|
355
|
+
class StaleTokenRetry < StandardError; end
|
|
356
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module GrowwMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class Authenticate < GrowwMcp::BaseTool
|
|
8
|
+
description "Generate a fresh Groww access token using configured credentials (TOTP, API Key, or manual token). " \
|
|
9
|
+
"Call this if other tools return authentication errors. The token is valid until 6:00 AM IST daily."
|
|
10
|
+
|
|
11
|
+
input_schema(
|
|
12
|
+
properties: {
|
|
13
|
+
force: {
|
|
14
|
+
type: "boolean",
|
|
15
|
+
description: "Force re-authentication even if current token is valid (default: false)",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def call(server_context:, force: false)
|
|
22
|
+
auth = server_context[:auth]
|
|
23
|
+
|
|
24
|
+
if force || !auth.token_valid?
|
|
25
|
+
auth.authenticate!
|
|
26
|
+
MCP::Tool::Response.new([{
|
|
27
|
+
type: "text",
|
|
28
|
+
text: "✅ Authenticated successfully. Token valid until #{auth.token_expiry || 'unknown (manual token)'}.",
|
|
29
|
+
}])
|
|
30
|
+
else
|
|
31
|
+
MCP::Tool::Response.new([{
|
|
32
|
+
type: "text",
|
|
33
|
+
text: "Token is still valid (expires: #{auth.token_expiry}). Use force: true to re-authenticate.",
|
|
34
|
+
}])
|
|
35
|
+
end
|
|
36
|
+
rescue GrowwMcp::AuthError => e
|
|
37
|
+
MCP::Tool::Response.new([{ type: "text", text: "❌ Authentication failed: #{e.message}" }])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GrowwMcp
|
|
4
|
+
module Tools
|
|
5
|
+
class SearchInstruments < GrowwMcp::BaseTool
|
|
6
|
+
description "Search for tradable instruments (stocks, F&O, ETFs) by name or symbol. " \
|
|
7
|
+
"Returns matching instruments with their trading symbols, exchanges, and segments. " \
|
|
8
|
+
"Use this to find the correct trading_symbol before placing orders. " \
|
|
9
|
+
"Search runs locally against Groww's public instruments CSV (cached 24h) — " \
|
|
10
|
+
"fast, and works without the Live Data subscription."
|
|
11
|
+
|
|
12
|
+
input_schema(
|
|
13
|
+
properties: {
|
|
14
|
+
query: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Search query — stock name or symbol (e.g., 'Reliance', 'NIFTY', 'HDFC'). " \
|
|
17
|
+
"Case-insensitive substring match on trading symbol and name.",
|
|
18
|
+
},
|
|
19
|
+
limit: {
|
|
20
|
+
type: "integer",
|
|
21
|
+
description: "Maximum number of results to return (default: 20)",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
required: ["query"],
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def call(server_context:, query:, limit: 20)
|
|
29
|
+
client = server_context[:client]
|
|
30
|
+
result = client.search_instruments(query, limit: limit)
|
|
31
|
+
if result.empty?
|
|
32
|
+
return MCP::Tool::Response.new([{ type: "text", text: "No instruments matched '#{query}'." }])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
format_response(result)
|
|
36
|
+
rescue GrowwMcp::ApiError => e
|
|
37
|
+
error_response(e)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class GetInstrumentDetail < GrowwMcp::BaseTool
|
|
43
|
+
description "Get detailed information about a specific instrument including lot size, tick size, " \
|
|
44
|
+
"expiry date (for F&O), and other contract details. " \
|
|
45
|
+
"Resolved locally from Groww's public instruments CSV (cached 24h) — " \
|
|
46
|
+
"works without the Live Data subscription."
|
|
47
|
+
|
|
48
|
+
input_schema(
|
|
49
|
+
properties: {
|
|
50
|
+
trading_symbol: {
|
|
51
|
+
type: "string",
|
|
52
|
+
description: "Trading symbol (e.g., RELIANCE, NIFTY24JUL25000CE)",
|
|
53
|
+
},
|
|
54
|
+
exchange: {
|
|
55
|
+
type: "string",
|
|
56
|
+
enum: %w[NSE BSE],
|
|
57
|
+
description: "Exchange (default: NSE)",
|
|
58
|
+
},
|
|
59
|
+
segment: {
|
|
60
|
+
type: "string",
|
|
61
|
+
enum: %w[CASH FNO],
|
|
62
|
+
description: "Market segment (default: CASH)",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: ["trading_symbol"],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
def call(server_context:, trading_symbol:, exchange: "NSE", segment: "CASH")
|
|
70
|
+
client = server_context[:client]
|
|
71
|
+
result = client.instrument_detail(trading_symbol, exchange: exchange, segment: segment)
|
|
72
|
+
if result.nil?
|
|
73
|
+
return MCP::Tool::Response.new([{
|
|
74
|
+
type: "text",
|
|
75
|
+
text: "No instrument found for '#{trading_symbol}' (#{exchange}/#{segment}). " \
|
|
76
|
+
"Try search_instruments to find the exact trading symbol.",
|
|
77
|
+
}])
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
format_response(result)
|
|
81
|
+
rescue GrowwMcp::ApiError => e
|
|
82
|
+
error_response(e)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class DownloadInstruments < GrowwMcp::BaseTool
|
|
88
|
+
description "Download the complete list of all tradable instruments as CSV. " \
|
|
89
|
+
"Returns a sample of the first 20 rows and total count. " \
|
|
90
|
+
"Useful for finding exact trading symbols, ISIN codes, and lot sizes."
|
|
91
|
+
|
|
92
|
+
input_schema(properties: {})
|
|
93
|
+
|
|
94
|
+
class << self
|
|
95
|
+
def call(server_context:)
|
|
96
|
+
client = server_context[:client]
|
|
97
|
+
csv_data = client.download_instruments
|
|
98
|
+
|
|
99
|
+
lines = csv_data.split("\n")
|
|
100
|
+
sample = lines[0..20].join("\n")
|
|
101
|
+
|
|
102
|
+
MCP::Tool::Response.new([{
|
|
103
|
+
type: "text",
|
|
104
|
+
text: "Total instruments: #{lines.length - 1}\n\nSample (first 20 rows):\n#{sample}",
|
|
105
|
+
}])
|
|
106
|
+
rescue => e
|
|
107
|
+
error_response(e)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|