Skip to content

Commit 06a7199

Browse files
authored
feat: MCP first MVP (#97)
1 parent 3cf39c3 commit 06a7199

File tree

6 files changed

+262
-0
lines changed

6 files changed

+262
-0
lines changed

go.mod

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,38 @@ require (
66
github.com/antlr4-go/antlr/v4 v4.13.1
77
github.com/getsentry/sentry-go v0.35.1
88
github.com/gkampitakis/go-snaps v0.5.14
9+
github.com/mark3labs/mcp-go v0.41.1
910
github.com/sergi/go-diff v1.4.0
1011
github.com/spf13/cobra v1.10.1
1112
github.com/stretchr/testify v1.11.1
1213
)
1314

1415
require (
16+
github.com/bahlo/generic-list-go v0.2.0 // indirect
17+
github.com/buger/jsonparser v1.1.1 // indirect
1518
github.com/davecgh/go-spew v1.1.1 // indirect
1619
github.com/gkampitakis/ciinfo v0.3.3 // indirect
1720
github.com/gkampitakis/go-diff v1.3.2 // indirect
1821
github.com/goccy/go-yaml v1.18.0 // indirect
22+
github.com/google/uuid v1.6.0 // indirect
1923
github.com/inconshreveable/mousetrap v1.1.0 // indirect
24+
github.com/invopop/jsonschema v0.13.0 // indirect
2025
github.com/kr/pretty v0.3.1 // indirect
2126
github.com/kr/text v0.2.0 // indirect
27+
github.com/mailru/easyjson v0.7.7 // indirect
2228
github.com/maruel/natural v1.1.1 // indirect
2329
github.com/pmezard/go-difflib v1.0.0 // indirect
2430
github.com/rogpeppe/go-internal v1.14.1 // indirect
2531
github.com/segmentio/asm v1.1.3 // indirect
2632
github.com/segmentio/encoding v0.3.4 // indirect
33+
github.com/spf13/cast v1.7.1 // indirect
2734
github.com/spf13/pflag v1.0.10 // indirect
2835
github.com/tidwall/gjson v1.18.0 // indirect
2936
github.com/tidwall/match v1.2.0 // indirect
3037
github.com/tidwall/pretty v1.2.1 // indirect
3138
github.com/tidwall/sjson v1.2.5 // indirect
39+
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
40+
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
3241
go.lsp.dev/jsonrpc2 v0.10.0 // indirect
3342
go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect
3443
go.lsp.dev/protocol v0.12.0

go.sum

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYW
22
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
33
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
44
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
5+
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
6+
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
7+
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
8+
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
59
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
610
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
711
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
812
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
913
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
14+
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
15+
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
1016
github.com/getsentry/sentry-go v0.35.1 h1:iopow6UVLE2aXu46xKVIs8Z9D/YZkJrHkgozrxa+tOQ=
1117
github.com/getsentry/sentry-go v0.35.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
1218
github.com/gkampitakis/ciinfo v0.3.3 h1:28PgAHtW3wG7UCAKuCK+17rBib9iqtLjajuWsVLUPQY=
@@ -22,15 +28,24 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
2228
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
2329
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
2430
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
31+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
32+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
2533
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
2634
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
35+
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
36+
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
37+
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
2738
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
2839
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
2940
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3041
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
3142
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
3243
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
3344
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
45+
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
46+
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
47+
github.com/mark3labs/mcp-go v0.41.1 h1:w78eWfiQam2i8ICL7AL0WFiq7KHNJQ6UB53ZVtH4KGA=
48+
github.com/mark3labs/mcp-go v0.41.1/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
3449
github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
3550
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
3651
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
@@ -51,6 +66,8 @@ github.com/segmentio/encoding v0.3.4 h1:WM4IBnxH8B9TakiM2QD5LyNl9JSndh88QbHqVC+P
5166
github.com/segmentio/encoding v0.3.4/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM=
5267
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
5368
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
69+
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
70+
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
5471
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
5572
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
5673
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -86,6 +103,10 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
86103
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
87104
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
88105
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
106+
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
107+
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
108+
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
109+
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
89110
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
90111
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
91112
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=

internal/analysis/diagnostic_kind.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ func SeverityToAnsiString(s Severity) string {
3636
}
3737
}
3838

39+
func SeverityToString(s Severity) string {
40+
switch s {
41+
case ErrorSeverity:
42+
return "Error"
43+
case WarningSeverity:
44+
return "Warning"
45+
case Information:
46+
return "Info"
47+
case Hint:
48+
return "Hint"
49+
default:
50+
return utils.NonExhaustiveMatchPanic[string](s)
51+
}
52+
}
53+
3954
type DiagnosticKind interface {
4055
Message() string
4156
Severity() Severity

internal/cmd/mcp.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package cmd
2+
3+
import (
4+
"github.com/formancehq/numscript/internal/mcp_impl"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
var mcpCmd = &cobra.Command{
9+
Use: "mcp",
10+
Short: "Run the mcp server",
11+
Hidden: true,
12+
RunE: func(cmd *cobra.Command, args []string) error {
13+
err := mcp_impl.RunServer()
14+
if err != nil {
15+
cmd.SilenceErrors = true
16+
cmd.SilenceUsage = true
17+
return err
18+
}
19+
20+
return nil
21+
},
22+
}

internal/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func Execute(options CliOptions) {
2424
rootCmd.Version = options.Version
2525

2626
rootCmd.AddCommand(lspCmd)
27+
rootCmd.AddCommand(mcpCmd)
2728
rootCmd.AddCommand(checkCmd)
2829
rootCmd.AddCommand(getTestCmd())
2930
rootCmd.AddCommand(getTestInitCmd())

internal/mcp_impl/handlers.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package mcp_impl
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"math/big"
7+
"strings"
8+
9+
"github.com/formancehq/numscript/internal/analysis"
10+
"github.com/formancehq/numscript/internal/interpreter"
11+
"github.com/formancehq/numscript/internal/parser"
12+
"github.com/mark3labs/mcp-go/mcp"
13+
"github.com/mark3labs/mcp-go/server"
14+
)
15+
16+
func parseBalancesJson(balancesRaw any) (interpreter.Balances, *mcp.CallToolResult) {
17+
balances, ok := balancesRaw.(map[string]any)
18+
if !ok {
19+
return interpreter.Balances{}, mcp.NewToolResultError(fmt.Sprintf("Expected an object as balances, got: <%#v>", balancesRaw))
20+
}
21+
22+
iBalances := interpreter.Balances{}
23+
for account, assetsRaw := range balances {
24+
if iBalances[account] == nil {
25+
iBalances[account] = interpreter.AccountBalance{}
26+
}
27+
28+
assets, ok := assetsRaw.(map[string]any)
29+
if !ok {
30+
return interpreter.Balances{}, mcp.NewToolResultError(fmt.Sprintf("Expected nested object for account %v", account))
31+
}
32+
33+
for asset, amountRaw := range assets {
34+
amount, ok := amountRaw.(float64)
35+
if !ok {
36+
return interpreter.Balances{}, mcp.NewToolResultError(fmt.Sprintf("Expected float for amount: %v", amountRaw))
37+
}
38+
39+
n, _ := big.NewFloat(amount).Int(new(big.Int))
40+
iBalances[account][asset] = n
41+
}
42+
}
43+
return iBalances, nil
44+
}
45+
46+
func parseVarsJson(varsRaw any) (map[string]string, *mcp.CallToolResult) {
47+
vars, ok := varsRaw.(map[string]any)
48+
if !ok {
49+
return map[string]string{}, mcp.NewToolResultError(fmt.Sprintf("Expected an object as vars, got: <%#v>", varsRaw))
50+
}
51+
52+
iVars := map[string]string{}
53+
for key, rawValue := range vars {
54+
55+
value, ok := rawValue.(string)
56+
if !ok {
57+
return map[string]string{}, mcp.NewToolResultError(fmt.Sprintf("Expected %s var to be a string, got: %T instead", key, rawValue))
58+
}
59+
60+
iVars[key] = value
61+
}
62+
63+
return iVars, nil
64+
}
65+
66+
func addEvalTool(s *server.MCPServer) {
67+
tool := mcp.NewTool("evaluate",
68+
mcp.WithDescription("Evaluate a numscript program"),
69+
mcp.WithIdempotentHintAnnotation(true),
70+
mcp.WithReadOnlyHintAnnotation(true),
71+
mcp.WithOpenWorldHintAnnotation(false),
72+
mcp.WithString("script",
73+
mcp.Required(),
74+
mcp.Description("The numscript source"),
75+
),
76+
mcp.WithObject("balances",
77+
mcp.Required(),
78+
mcp.Description(`The accounts' balances. A nested map from the account name, to the asset, to its integer amount.
79+
For example: { "alice": { "USD/2": 100, "EUR/2": -42 }, "bob": { "BTC": 1 } }
80+
`),
81+
),
82+
mcp.WithObject("vars",
83+
mcp.Required(),
84+
mcp.Description(`The stringified variables to be passed to the script's "vars" block.
85+
For example: { "acc": "alice", "mon": "EUR 100" } can be passed to the following script:
86+
vars {
87+
monetary $mon
88+
account $acc
89+
}
90+
91+
send $mon (
92+
source = $acc
93+
destination = @world
94+
)
95+
`),
96+
),
97+
)
98+
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
99+
script, err := request.RequireString("script")
100+
if err != nil {
101+
return mcp.NewToolResultError(err.Error()), nil
102+
}
103+
104+
parsed := parser.Parse(script)
105+
if len(parsed.Errors) != 0 {
106+
out := make([]string, len(parsed.Errors))
107+
for index, err := range parsed.Errors {
108+
out[index] = err.Msg
109+
}
110+
mcp.NewToolResultError(strings.Join(out, ", "))
111+
}
112+
113+
balances, mcpErr := parseBalancesJson(request.GetArguments()["balances"])
114+
if mcpErr != nil {
115+
return mcpErr, nil
116+
}
117+
118+
vars, mcpErr := parseVarsJson(request.GetArguments()["vars"])
119+
if mcpErr != nil {
120+
return mcpErr, nil
121+
}
122+
123+
out, iErr := interpreter.RunProgram(
124+
ctx,
125+
parsed.Value,
126+
vars,
127+
interpreter.StaticStore{
128+
Balances: balances,
129+
},
130+
map[string]struct{}{},
131+
)
132+
if iErr != nil {
133+
return mcp.NewToolResultError(iErr.Error()), nil
134+
}
135+
return mcp.NewToolResultJSON(*out)
136+
})
137+
}
138+
139+
func addCheckTool(s *server.MCPServer) {
140+
tool := mcp.NewTool("check",
141+
mcp.WithDescription("Check a program for parsing error or static analysis errors"),
142+
mcp.WithIdempotentHintAnnotation(true),
143+
mcp.WithReadOnlyHintAnnotation(true),
144+
mcp.WithOpenWorldHintAnnotation(false),
145+
mcp.WithString("script",
146+
mcp.Required(),
147+
mcp.Description("The numscript source"),
148+
),
149+
)
150+
151+
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
152+
script, err := request.RequireString("script")
153+
if err != nil {
154+
return mcp.NewToolResultError(err.Error()), nil
155+
}
156+
157+
checkResult := analysis.CheckSource(script)
158+
159+
var errors []any
160+
for _, d := range checkResult.Diagnostics {
161+
errors = append(errors, map[string]any{
162+
"kind": d.Kind.Message(),
163+
"severity": analysis.SeverityToString(d.Kind.Severity()),
164+
"span": d.Range,
165+
})
166+
}
167+
168+
return mcp.NewToolResultJSON(map[string]any{
169+
"errors": errors,
170+
})
171+
})
172+
}
173+
174+
func RunServer() error {
175+
// Create a new MCP server
176+
s := server.NewMCPServer(
177+
"Numscript",
178+
"0.0.1",
179+
server.WithToolCapabilities(false),
180+
server.WithRecovery(),
181+
server.WithInstructions(`
182+
You're a Numscript expert AI assistant. Numscript is a DSL that allows modeling financial transactions in an easy and declarative way. Numscript scripts always terminate.
183+
`),
184+
)
185+
addEvalTool(s)
186+
addCheckTool(s)
187+
188+
// Start the server
189+
if err := server.ServeStdio(s); err != nil {
190+
return err
191+
}
192+
193+
return nil
194+
}

0 commit comments

Comments
 (0)