-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRakefile
More file actions
825 lines (751 loc) · 31.9 KB
/
Copy pathRakefile
File metadata and controls
825 lines (751 loc) · 31.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
#!/usr/bin/env rake
require "bundler/gem_tasks"
require "yard"
require "rake/testtask"
# Several MCP/debug tasks need to run `Parse.setup(...)` against a
# local Parse instance. This helper preserves the local-stack
# convenience defaults while refusing to apply those defaults against
# anything that isn't a loopback URL — so a developer who pointed
# `PARSE_SERVER_URL` at a real Parse Server but forgot to set the
# secret env vars gets a loud abort instead of a silent boot with
# placeholder credentials.
#
# @return [Array(String, String, String, String)]
# server_url, application_id, api_key, master_key
def mcp_credentials_or_abort!
server_url = ENV["PARSE_SERVER_URL"] || "http://localhost:29337/parse"
app_id = ENV["PARSE_APP_ID"]
rest_api_key = ENV["PARSE_API_KEY"]
master_key = ENV["PARSE_MASTER_KEY"]
is_local = server_url =~ %r{\Ahttps?://(?:localhost|127\.0\.0\.1|::1|\[::1\])(?::|/|\z)}
if app_id.to_s.empty? || master_key.to_s.empty?
if is_local
app_id = (app_id.to_s.empty? ? "psnextItAppId" : app_id)
rest_api_key = (rest_api_key.to_s.empty? ? "myApiKey" : rest_api_key)
master_key = (master_key.to_s.empty? ? "psnextItMasterKey" : master_key)
else
abort "[Rakefile] PARSE_SERVER_URL=#{server_url} is not local; refusing to fall back to " \
"placeholder credentials. Set PARSE_APP_ID and PARSE_MASTER_KEY explicitly."
end
end
[server_url, app_id, rest_api_key, master_key]
end
# Resolve the identity for `rake client:console`. Returns a session-token String,
# or nil to mean "anonymous" (no token, no master). Order: PARSE_SESSION_TOKEN,
# PARSE_CLIENT_ANONYMOUS=true, PARSE_LOGIN_USER (+PARSE_LOGIN_PASSWORD), else
# prompt for login / token / anon. The login path uses the configured default
# client (the master client set up just before this is called).
def client_console_token!
token = ENV["PARSE_SESSION_TOKEN"].to_s.strip
return token unless token.empty?
return nil if ENV["PARSE_CLIENT_ANONYMOUS"].to_s == "true"
user = ENV["PARSE_LOGIN_USER"].to_s.strip
if user.empty?
print "Identity? [login/token/anon] (login): "
case $stdin.gets.to_s.strip.downcase
when "anon", "anonymous" then return nil
when "token"
print "Session token (r:...): "
t = $stdin.gets.to_s.strip
return t.empty? ? nil : t
else
print "Username (blank for anonymous): "
user = $stdin.gets.to_s.strip
return nil if user.empty?
end
end
pwd = ENV["PARSE_LOGIN_PASSWORD"].to_s
if pwd.empty?
begin
require "io/console"
print "Password for #{user}: "
pwd = $stdin.noecho(&:gets).to_s
puts
rescue LoadError, IOError, SystemCallError
# io/console missing, or stdin is not a TTY (piped input raises
# Errno::ENOTTY, a SystemCallError — not an IOError). Fall back to a
# plain read; warn that it will echo.
warn "[client:console] WARNING: cannot disable echo; password will be visible."
print "Password for #{user}: "
pwd = $stdin.gets.to_s
end
end
u = console_login_with_optional_mfa(user, pwd.chomp)
abort "[client:console] login failed for #{user.inspect}" if u.nil? || u.session_token.to_s.empty?
puts "Logged in as #{u.username} (#{u.id})."
u.session_token
end
# Log `user` in, transparently handling an MFA-enrolled account. If the server
# reports that additional MFA auth is required, prompt for a TOTP / recovery
# code (or read +PARSE_LOGIN_MFA+ for non-interactive use) and retry via
# {Parse::User.login_with_mfa}. Returns a logged-in {Parse::User}, or nil when
# the credentials themselves are rejected (so the caller's "login failed" abort
# still fires for a bad password).
def console_login_with_optional_mfa(user, pwd)
# Parse Server signals "this account needs an MFA token" two ways depending on
# the error code path: a returned error response ("Missing additional
# authData ...") or a raised Parse::Error for the OTHER_CAUSE (code <= 100)
# variant. Treat both as "prompt for MFA"; anything else is a real credential
# failure and must NOT trigger an MFA prompt.
mfa_indicator = /additional\s+authData|missing.*mfa|\bMFA\b/i
begin
response = Parse.client.login(user, pwd)
if response.success?
return Parse::User.with_authdata_trust { Parse::User.build(response.result) }
end
return nil unless response.error.to_s.match?(mfa_indicator)
rescue Parse::Error, Parse::Client::ResponseError => e
raise unless e.message.to_s.match?(mfa_indicator)
end
token = ENV["PARSE_LOGIN_MFA"].to_s.strip
if token.empty?
print "MFA token (authenticator code or recovery code): "
token = $stdin.gets.to_s.strip
end
abort "[client:console] MFA token required for #{user.inspect}" if token.empty?
# A wrong/expired token can surface either as Parse::MFA::VerificationError or,
# depending on the server error code path, as a generic Parse::Error (e.g.
# ServiceUnavailableError for the OTHER_CAUSE code) or a nil return. Since a
# token was supplied here, treat any failure as an MFA verification failure
# and abort cleanly rather than letting an unhandled exception escape.
result =
begin
Parse::User.login_with_mfa(user, pwd, token)
rescue Parse::MFA::VerificationError, Parse::Error => e
abort "[client:console] MFA verification failed for #{user.inspect}: #{e.message}"
end
abort "[client:console] MFA verification failed for #{user.inspect}" if result.nil?
result
end
# Default test task runs all tests with Docker enabled.
#
# `*disruptive*` tests are EXCLUDED here: they stop/restart the shared
# Parse Server container, which would flake any other test loaded into the
# same process. Run them on their own via `rake test:integration:disruptive`.
Rake::TestTask.new do |t|
ENV['PARSE_TEST_USE_DOCKER'] = 'true'
t.libs << "lib/parse/stack"
t.test_files = FileList["test/lib/**/*_test.rb"].exclude("test/lib/**/*disruptive*")
t.warning = false
t.verbose = true
end
# Shared runner for the file-per-process test tasks. Each test file runs in its
# own Ruby process (isolation against the shared Parse Server); output streams
# live, and — for trackability — a PASS/FAIL + duration line per file is printed
# to STDOUT *and* appended to a progress log you can `tail -f` from another
# shell while the run is in flight (works even when the run is backgrounded or
# piped, where STDOUT would otherwise buffer until completion).
#
# Env knobs:
# TEST_PATTERN=<substr> run only files whose path includes <substr>
# (e.g. TEST_PATTERN=webhook)
# CONTINUE_ON_FAILURE=false stop at the first failing file
# (default: run them all, then list every failure)
#
# Exits non-zero if any file failed.
def run_test_files!(label, files, log:)
$stdout.sync = true
require "fileutils"
if (pattern = ENV["TEST_PATTERN"].to_s).length.positive?
files = files.select { |f| f.include?(pattern) }
puts "TEST_PATTERN=#{pattern} -> #{files.length} matching file(s)"
end
continue = ENV.fetch("CONTINUE_ON_FAILURE", "true") != "false"
FileUtils.mkdir_p(File.dirname(log))
total = files.length
started = Time.now
results = []
File.write(log, "#{label}: #{total} files, started #{started}\n")
puts "\n>> #{label}: #{total} files (progress log: #{log})"
files.each_with_index do |file, i|
n = i + 1
puts "\n" + "=" * 80
puts "[#{n}/#{total}] #{file}"
puts "=" * 80
t0 = Time.now
# Always go through `bundle exec` so the locked gem versions win. With a
# bare `ruby`, RubyGems activates the newest installed minitest (6.0.x),
# which dropped the bundled `minitest/mock`; the standalone `minitest-mock`
# gem then can't co-activate and `test_helper.rb` fails to load every file.
ok = system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}")
dt = Time.now - t0
results << [file, ok, dt]
summary = format("[%d/%d] %-4s %7.1fs %s", n, total, ok ? "PASS" : "FAIL", dt, file)
puts summary
File.open(log, "a") { |f| f.puts summary }
if !ok && !continue
File.open(log, "a") { |f| f.puts "STOPPED at first failure (CONTINUE_ON_FAILURE=false)" }
break
end
end
elapsed = Time.now - started
passed = results.count { |_, ok, _| ok }
failed = results.reject { |_, ok, _| ok }
footer = format("%s: %d/%d passed in %.1fs (%.1f min)",
label, passed, results.length, elapsed, elapsed / 60.0)
puts "\n" + "=" * 80
puts footer
unless failed.empty?
puts "Failed (#{failed.length}):"
failed.each { |f, _, d| puts format(" FAIL %7.1fs %s", d, f) }
end
puts "=" * 80
File.open(log, "a") do |f|
f.puts footer
failed.each { |ff, _, d| f.puts format(" FAIL %7.1fs %s", d, ff) }
end
exit(1) unless failed.empty?
end
# Integration tests require Docker
namespace :test do
desc "Run all integration tests (requires Docker). " \
"Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
task :integration do
# Disruptive tests (server stop/restart) are run separately via
# `test:integration:disruptive` so they never interleave with — and
# flake — the rest of the integration suite against the shared server.
files = FileList["test/lib/**/*integration_test.rb"]
.exclude("test/lib/**/*disruptive*")
run_test_files!("Integration tests", files, log: "tmp/integration-progress.log")
end
desc "Run unit tests only (no Docker required). " \
"Knobs: TEST_PATTERN=<substr>, CONTINUE_ON_FAILURE=false."
task :unit do
files = FileList["test/lib/**/*_test.rb"]
.exclude("test/lib/**/*integration_test.rb")
.exclude("test/lib/**/*disruptive*")
run_test_files!("Unit tests", files, log: "tmp/unit-progress.log")
end
namespace :integration do
desc "Run DISRUPTIVE integration tests (stop/restart the Parse Server " \
"container). Run in isolation — these are excluded from the normal " \
"test / test:integration / test:unit runs."
task :disruptive do
disruptive_files = FileList["test/lib/**/*disruptive*_test.rb"]
if disruptive_files.empty?
puts "No disruptive test files found."
next
end
puts "Running #{disruptive_files.length} disruptive test file(s)..."
disruptive_files.each_with_index do |file, index|
puts "\n" + "=" * 80
puts "Running disruptive test #{index + 1}/#{disruptive_files.length}: #{file}"
puts "=" * 80
# Each file runs in its own process so a server outage in one cannot
# bleed into the next.
system("PARSE_TEST_USE_DOCKER=true bundle exec ruby -Ilib:test #{file}") || begin
# A disruptive test may have left the server down on failure; bring
# it back so a follow-up run / other tasks start from a clean state.
system("docker start #{ENV["PSNEXT_PREFIX"] || "psnext-it"}-server", out: IO::NULL, err: IO::NULL)
exit(1)
end
end
puts "\n✅ All disruptive tests completed successfully!"
end
end
desc "List all available test files"
task :list do
puts "\nIntegration Tests:"
FileList["test/lib/**/*integration_test.rb"].exclude("test/lib/**/*disruptive*").each { |f| puts " #{f}" }
puts "\nDisruptive Integration Tests (run via test:integration:disruptive):"
FileList["test/lib/**/*disruptive*_test.rb"].each { |f| puts " #{f}" }
puts "\nUnit Tests:"
FileList["test/lib/**/*_test.rb"]
.exclude("test/lib/**/*integration_test.rb")
.exclude("test/lib/**/*disruptive*")
.each { |f| puts " #{f}" }
end
# ---------------------------------------------------------------------------
# MCP protocol conformance via Anthropic's official mcp-inspector tool.
#
# Boots a local MCPServer against a configured Parse Server, then runs
# @modelcontextprotocol/inspector in CLI mode to validate the MCP wire
# protocol (initialize handshake, tools/list, tools/call, prompts/list,
# resources/list, error envelopes). Catches protocol regressions that
# in-process integration tests can miss because they exercise the Ruby
# call surface, not the JSON wire format an external MCP client sees.
#
# Requirements:
# - npx on PATH (Node.js 18+)
# - A running Parse Server (e.g., `docker-compose -f scripts/docker/
# docker-compose.test.yml up -d`)
# - Env: PARSE_SERVER_URL, PARSE_APP_ID, PARSE_API_KEY (defaults match
# the Docker compose setup in scripts/docker/docker-compose.test.yml)
#
# Usage:
# rake test:mcp_inspector
# rake test:mcp_inspector METHOD=tools/list # override target method
# ---------------------------------------------------------------------------
desc "Validate MCP protocol with Anthropic's mcp-inspector (requires npx)"
task :mcp_inspector do
require "net/http"
require "uri"
require "fileutils"
unless system("which npx > /dev/null 2>&1")
abort "[mcp_inspector] npx not found on PATH. Install Node.js 18+ or use `nvm use 18`."
end
port = ENV["MCP_INSPECTOR_PORT"] || "3099"
api_key = ENV["MCP_INSPECTOR_KEY"] || "rake-inspector-key"
method = ENV["METHOD"] || "tools/list"
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
boot = <<~RUBY
$LOAD_PATH.unshift(File.expand_path('lib'))
require 'parse-stack'
Parse.setup(
server_url: #{server_url.inspect},
application_id: #{app_id.inspect},
api_key: #{rest_api_key.inspect},
master_key: #{master_key.inspect},
)
ENV['PARSE_MCP_ENABLED'] = 'true'
Parse.mcp_server_enabled = true
Parse::Agent.mcp_enabled = true
require 'parse/agent/mcp_server'
Parse::Agent::MCPServer.run(
port: #{port.to_i},
host: '127.0.0.1',
permissions: :readonly,
api_key: #{api_key.inspect},
)
RUBY
log_path = "tmp/mcp-inspector-server.log"
FileUtils.mkdir_p("tmp")
pid = Process.spawn("ruby", "-e", boot, out: log_path, err: log_path)
begin
ready = false
40.times do
sleep 0.25
begin
uri = URI("http://127.0.0.1:#{port}/health")
ready = (Net::HTTP.get_response(uri).code == "200")
break if ready
rescue Errno::ECONNREFUSED, Errno::EADDRINUSE
# retry
end
end
unless ready
warn "[mcp_inspector] MCPServer failed to become healthy on port #{port}. Server log:"
warn(File.read(log_path)) rescue nil
abort "[mcp_inspector] aborting"
end
puts "[mcp_inspector] MCPServer healthy on http://127.0.0.1:#{port}"
cmd = [
"npx", "--yes", "@modelcontextprotocol/inspector",
"--cli", "http://127.0.0.1:#{port}/mcp",
"--method", method,
"--header", "X-MCP-API-Key:#{api_key}",
]
puts "[mcp_inspector] $ #{cmd.join(" ")}"
ok = system(*cmd)
abort "[mcp_inspector] inspector exited non-zero" unless ok
puts "[mcp_inspector] protocol check passed"
ensure
if pid
Process.kill("TERM", pid) rescue nil
Process.wait(pid) rescue nil
end
end
end
end
task :default => :test
task :console do
exec "./bin/console"
end
task :c => :console
# ===========================================================================
# MCP namespace: interactive REPL and one-shot tool dispatch.
# ===========================================================================
namespace :mcp do
# -------------------------------------------------------------------------
# rake mcp:console
#
# Drops you into an IRB session with a pre-configured Parse::Agent and
# MCP helpers bound at the top level. Talk to the agent the same way an
# LLM would, but interactively from your terminal.
#
# Setup:
# - .env (or shell env) provides PARSE_SERVER_URL / PARSE_APP_ID /
# PARSE_API_KEY / PARSE_MASTER_KEY. Defaults match the Docker
# compose harness in scripts/docker/docker-compose.test.yml.
# - Optionally MCP_AGENT_PERMISSIONS=readonly|write|admin
# (default :readonly).
#
# Bindings available in the REPL:
# agent — the Parse::Agent instance.
# tools — print every tool the agent has access to.
# schemas — print every visible class name.
# t(name, **kwargs) — invoke a tool, return its result hash.
# q(class_name, ...) — shortcut for t(:query_class, class_name:, **opts).
# count(class_name) — shortcut for t(:count_objects, class_name:, ...).
# schema(class_name) — shortcut for t(:get_schema, class_name:).
# dispatch(method, params={}) — call MCPDispatcher.call(body:, agent:).
# prompts — print every registered + builtin prompt.
# render_prompt(name, args={}) — render a prompt to its message envelope.
#
# Example session:
# $ bundle exec rake mcp:console
# irb> tools
# irb> q("MCPSchoolTeacher", limit: 3)
# irb> count("MCPSchoolStudent")
# irb> dispatch("initialize")
# -------------------------------------------------------------------------
desc "Interactive MCP REPL: query a Parse::Agent like an LLM would, but with Ruby"
task :console do
require "irb"
require "json"
# dotenv is in the Gemfile :test, :development group; load .env if present.
begin
require "dotenv/load"
rescue LoadError
# dotenv not installed; rely on shell env vars
end
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
require "parse-stack"
require "parse/agent"
require "parse/agent/mcp_dispatcher"
require "parse/agent/prompts"
require "parse/agent/mcp_client"
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
permissions = (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym
Parse.setup(
server_url: server_url,
application_id: app_id,
api_key: rest_api_key,
master_key: master_key,
)
agent = Parse::Agent.new(permissions: permissions)
# Bind helpers as singleton methods on TOPLEVEL_BINDING so they're
# callable bare in the IRB session without a receiver.
Object.send(:define_method, :agent) { agent }
Object.send(:define_method, :_mcp_agent_const) { agent }
Object.send(:define_method, :tools) do
list = agent.tool_definitions(format: :mcp).map { |t| t[:name] || t["name"] }
puts list.sort.join("\n")
list.size
end
Object.send(:define_method, :schemas) do
result = agent.execute(:get_all_schemas)
unless result[:success]
puts "get_all_schemas failed: #{result[:error]}"
next nil
end
custom = (result[:data][:custom] || []).map { |c| c[:name] }
built_in = (result[:data][:built_in] || []).map { |c| c[:name] }
puts "Custom: #{custom.sort.join(", ")}"
puts "Built-in: #{built_in.sort.join(", ")}"
custom + built_in
end
Object.send(:define_method, :t) do |name, **kwargs|
agent.execute(name.to_sym, **kwargs)
end
Object.send(:define_method, :q) do |class_name, **opts|
t(:query_class, class_name: class_name, **opts)
end
Object.send(:define_method, :count) do |class_name, **opts|
t(:count_objects, class_name: class_name, **opts)
end
Object.send(:define_method, :schema) do |class_name|
t(:get_schema, class_name: class_name)
end
Object.send(:define_method, :dispatch) do |method, params = {}|
body = { "jsonrpc" => "2.0", "id" => SecureRandom.hex(4), "method" => method.to_s, "params" => params }
Parse::Agent::MCPDispatcher.call(body: body, agent: agent)
end
Object.send(:define_method, :prompts) do
list = Parse::Agent::Prompts.list.map { |p| p["name"] }
puts list.sort.join("\n")
list.size
end
Object.send(:define_method, :render_prompt) do |name, args = {}|
Parse::Agent::Prompts.render(name.to_s, args.transform_keys(&:to_s))
end
# When LLM_PROVIDER + LLM_API_KEY are in env (e.g. via .env), bind
# `mcp` as a conversational client. Lets you do:
# mcp.ask("how many students?")
# _.reply("just for Ms. Vasquez")
mcp = nil
if ENV["LLM_PROVIDER"]
begin
mcp = Parse::Agent::MCPClient.new(agent: agent)
Object.send(:define_method, :mcp) { mcp }
rescue ArgumentError => e
puts "[mcp:console] could not initialize MCPClient — #{e.message}"
puts "[mcp:console] set LLM_PROVIDER + LLM_API_KEY in your .env (see .env.sample)"
end
end
puts "=" * 70
puts "Parse::Agent MCP Console"
puts "=" * 70
puts "Server: #{server_url}"
puts "Permissions: #{permissions}"
puts "Agent: #{agent.class.name} (#{agent.allowed_tools.size} tools)"
puts "LLM client: " + (mcp ? "#{mcp.provider} / #{mcp.model}" : "DISABLED (set LLM_PROVIDER + LLM_API_KEY to enable mcp.ask)")
puts
puts "Try:"
if mcp
puts " mcp.ask('how many students do we have?')"
puts " _.reply('what about just for Ms. Vasquez?') # chain replies"
puts
end
puts " tools # list available tools"
puts " schemas # list visible Parse classes"
puts " q('User', limit: 3) # query_class shortcut"
puts " count('Song') # count_objects shortcut"
puts " schema('Song') # get_schema shortcut"
puts " t(:query_class, class_name: 'Song', where: { name: 'X' })"
puts " dispatch('tools/list') # MCPDispatcher round-trip"
puts " prompts # list registered prompts"
puts " render_prompt('parse_conventions')"
puts "=" * 70
IRB.start
end
# -------------------------------------------------------------------------
# rake mcp:chat
#
# Conversational CLI loop — talk to your Parse database via the MCP agent
# in plain English. Each turn drives the LLM through tool calls and prints
# the final answer; context persists across turns. Like a tiny REPL just
# for the MCP agent.
#
# Setup:
# - .env (or shell env) with LLM_PROVIDER + LLM_API_KEY (see .env.sample)
# - PARSE_SERVER_URL / PARSE_APP_ID / PARSE_API_KEY / PARSE_MASTER_KEY
# (defaults match the Docker compose harness)
#
# Slash commands inside the loop:
# /reset — start a fresh conversation (clear history)
# /compact — replace history with an LLM-generated summary (1 extra call)
# /tools — list available MCP tools
# /trace — toggle tool-call tracing on/off
# /cost — show running token + USD cost totals
# /history — print conversation history
# /exit — leave the chat (also: /quit, exit, quit, Ctrl-D, empty line)
# -------------------------------------------------------------------------
desc "Conversational CLI: talk to your Parse data via the MCP agent"
task :chat do
begin
require "dotenv/load"
rescue LoadError
end
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
require "parse-stack"
require "parse/agent"
require "parse/agent/mcp_client"
unless ENV["LLM_PROVIDER"]
abort "[mcp:chat] LLM_PROVIDER is not set. Add it to .env (see .env.sample). " \
"Supported providers: openai, anthropic, lmstudio."
end
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
Parse.setup(
server_url: server_url,
application_id: app_id,
api_key: rest_api_key,
master_key: master_key,
)
permissions = (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym
agent = Parse::Agent.new(permissions: permissions)
client = Parse::Agent::MCPClient.new(agent: agent)
trace = (ENV["MCP_CHAT_TRACE"] || "false") == "true"
slash_help = lambda do
puts "Slash commands:"
puts " /help — print this list"
puts " /reset — clear conversation history"
puts " /compact — replace history with an LLM-generated summary"
puts " /tools — list MCP tools the agent has access to"
puts " /trace — toggle per-turn tool-call tracing on/off"
puts " /cost — show running token + USD totals (and last turn)"
puts " /history — print the conversation log"
puts " /exit — leave (also /quit, exit, quit, Ctrl-D, empty line)"
end
puts "=" * 70
puts "Parse MCP Chat — #{client.provider} / #{client.model}"
puts "Permissions: #{permissions} | Trace: #{trace ? "on" : "off"}"
puts "Type your question. Type /help for slash commands."
puts "=" * 70
loop do
print "\n> "
line = $stdin.gets
break if line.nil? # Ctrl-D
line = line.strip
next if line.empty?
case line
when "/exit", "/quit", "exit", "quit"
break
when "/help"
slash_help.call
next
when "/reset"
client.reset!
puts "[conversation cleared]"
next
when "/compact"
before = client.usage.total_tokens
summary = client.compact!
if summary.empty?
puts "[nothing to compact]"
else
delta = client.usage.total_tokens - before
puts "[compacted; +#{delta} tokens spent on summary]"
puts " summary: #{summary[0, 200]}#{summary.length > 200 ? "…" : ""}"
end
next
when "/tools"
puts agent.tool_definitions(format: :mcp).map { |t| t[:name] || t["name"] }.sort.join("\n")
next
when "/trace"
trace = !trace
puts "[trace #{trace ? "on" : "off"}]"
next
when "/cost"
u = client.usage
last = client.last_call_usage
printf " session: %d in + %d out = %d tokens $%.4f\n",
u.prompt_tokens, u.completion_tokens, u.total_tokens, u.cost_usd
if last
printf " last: %d in + %d out = %d tokens $%.6f\n",
last.prompt_tokens, last.completion_tokens, last.total_tokens, last.cost_usd
end
next
when "/history"
client.history.each_with_index do |m, i|
puts " #{i + 1}. [#{m[:role]}] #{m[:content].to_s[0, 120]}"
end
next
end
begin
result = client.ask(line, reset: false)
if trace && result.tool_calls.any?
puts "─── tool calls ───"
result.tool_calls.each_with_index do |tc, i|
args = tc[:arguments].is_a?(Hash) ? tc[:arguments].inspect : tc[:arguments].to_s
puts " #{i + 1}. #{tc[:name]}(#{args})"
end
end
puts
puts result.text.to_s.empty? ? "[empty response]" : result.text
if trace && result.usage && result.usage.total_tokens.positive?
printf "[%d tokens / $%.6f this turn session: %d / $%.4f]\n",
result.usage.total_tokens, result.usage.cost_usd,
client.usage.total_tokens, client.usage.cost_usd
end
rescue Interrupt
puts "\n[interrupted]"
next
rescue => e
puts "[error] #{e.class}: #{e.message}"
end
end
puts "\nbye"
end
# -------------------------------------------------------------------------
# rake "mcp:tool[query_class,{\"class_name\":\"Song\",\"limit\":3}]"
#
# One-shot tool dispatch from the command line. The first arg is the tool
# name; the second is a JSON object of keyword arguments. Result printed
# as pretty JSON. Useful for ad-hoc smoke checks without spinning up IRB.
# -------------------------------------------------------------------------
desc "One-shot tool call: rake 'mcp:tool[name,jsonArgs]'"
task :tool, [:name, :args_json] do |_t, args|
begin
require "dotenv/load"
rescue LoadError
end
require "json"
require "parse-stack"
require "parse/agent"
tool_name = (args[:name] || abort("usage: rake 'mcp:tool[name,jsonArgs]'")).to_sym
raw = args[:args_json] || "{}"
parsed = JSON.parse(raw)
kwargs = parsed.transform_keys(&:to_sym)
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
Parse.setup(
server_url: server_url,
application_id: app_id,
api_key: rest_api_key,
master_key: master_key,
)
agent = Parse::Agent.new(permissions: (ENV["MCP_AGENT_PERMISSIONS"] || "readonly").to_sym)
result = agent.execute(tool_name, **kwargs)
puts JSON.pretty_generate(result)
exit(result[:success] ? 0 : 1)
end
end
# rake client:console — IRB whose DEFAULT client is a non-master client bound to
# a user's session, so every model query runs as that user (ACL/CLP enforced).
# Identity: PARSE_SESSION_TOKEN, or PARSE_LOGIN_USER/PARSE_LOGIN_PASSWORD, else
# prompt. Connection env matches the other tasks; the master key is used only to
# log in. Helpers in the REPL: client, whoami, as_master { … }.
namespace :client do
desc "Interactive console authenticated as a Parse user (session token or login), not master"
task :console do
require "irb"
begin; require "dotenv/load"; rescue LoadError; end
$LOAD_PATH.unshift(File.expand_path("lib", __dir__))
require "parse-stack"
server_url, app_id, rest_api_key, master_key = mcp_credentials_or_abort!
Parse.setup(server_url: server_url, application_id: app_id, api_key: rest_api_key, master_key: master_key)
master_client = Parse.client
token = client_console_token! # String, or nil for anonymous
# Models memoize their resolved client at the class level (`@client ||=`),
# so swapping clients[:default] alone is NOT enough — every swap must also
# drop those cached ivars or already-touched classes keep the old client.
reset_client_caches = lambda do
next unless defined?(Parse::Object)
[Parse::Object, *Parse::Object.descendants, Parse::Query].each do |k|
k.remove_instance_variable(:@client) if k.instance_variable_defined?(:@client)
end
end
# Make a non-master client (session-bound, or anonymous) the default so
# every model query runs as the user.
user_client = token ? master_client.become(token) : master_client.anonymous
Parse::Client.clients[:default] = user_client
reset_client_caches.call
Object.send(:define_method, :client) { user_client }
# Redacted: GET /users/me returns a Parse::User carrying the live
# sessionToken, and Parse::Object has no redacted #inspect, so returning the
# raw object would print the token in the REPL. Hand back a safe summary.
Object.send(:define_method, :whoami) do
next "anonymous (no session)" unless token
r = user_client.current_user(token)
next "whoami failed: #{r.error}" unless r.success?
u = r.result
{ "username" => u["username"], "objectId" => u["objectId"], "session_token" => "[FILTERED]" }
rescue StandardError => e
"whoami failed: #{e.message}"
end
# Escalate to the master key for the block, then restore the user client.
# Both swaps must reset the class client caches (see reset_client_caches),
# otherwise a class touched inside the block keeps master afterward, or a
# previously-touched class never escalates. Already-instantiated Parse::Query
# objects held in REPL locals keep their own client and are not reset.
Object.send(:define_method, :as_master) do |&blk|
Parse::Client.clients[:default] = master_client
reset_client_caches.call
blk.call
ensure
Parse::Client.clients[:default] = user_client
reset_client_caches.call
end
mode = token ? "a USER" : "ANONYMOUS"
puts "parse-stack-next client console — as #{mode} (no master key). Helpers: client, whoami, as_master { }."
ARGV.clear
IRB.start
end
end
desc "List undocumented methods"
task "yard:stats" do
exec "yard stats --list-undoc"
end
desc "Start the yard server"
task "docs" do
# `-t docs/yard-template` is required: `yard server` does not honor
# the --template-path line in .yardopts (that's only read by
# `yard doc`), so the custom theme overlay only takes effect when
# the path is passed on the command line.
exec "rm -rf ./yard && yard server --reload -t docs/yard-template"
end
YARD::Rake::YardocTask.new do |t|
t.files = ["lib/**/*.rb"] # optional
t.options = ["-o", "doc/parse-stack-next"] # optional
t.stats_options = ["--list-undoc"] # optional
end