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.
@@ -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