-
Notifications
You must be signed in to change notification settings - Fork 5
feat: MCP first MVP #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
03fb2fb
285a41c
c12ba77
8d7bae4
ded4c90
de2bf29
5720b1b
a5e51da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
package cmd | ||
|
||
import ( | ||
"github.com/formancehq/numscript/internal/mcp_impl" | ||
"github.com/spf13/cobra" | ||
) | ||
|
||
var mcpCmd = &cobra.Command{ | ||
Use: "mcp", | ||
Short: "Run the mcp server", | ||
Hidden: true, | ||
RunE: func(cmd *cobra.Command, args []string) error { | ||
err := mcp_impl.RunServer() | ||
if err != nil { | ||
cmd.SilenceErrors = true | ||
cmd.SilenceUsage = true | ||
return err | ||
} | ||
|
||
return nil | ||
}, | ||
} |
Original file line number | Diff line number | Diff line change | ||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,195 @@ | ||||||||||||||
package mcp_impl | ||||||||||||||
|
||||||||||||||
import ( | ||||||||||||||
"context" | ||||||||||||||
"fmt" | ||||||||||||||
"math/big" | ||||||||||||||
"strings" | ||||||||||||||
|
||||||||||||||
"github.com/formancehq/numscript/internal/analysis" | ||||||||||||||
"github.com/formancehq/numscript/internal/interpreter" | ||||||||||||||
"github.com/formancehq/numscript/internal/parser" | ||||||||||||||
"github.com/mark3labs/mcp-go/mcp" | ||||||||||||||
"github.com/mark3labs/mcp-go/server" | ||||||||||||||
) | ||||||||||||||
|
||||||||||||||
func parseBalancesJson(balancesRaw any) (interpreter.Balances, *mcp.CallToolResult) { | ||||||||||||||
balances, ok := balancesRaw.(map[string]any) | ||||||||||||||
if !ok { | ||||||||||||||
return interpreter.Balances{}, mcp.NewToolResultError(fmt.Sprintf("Expected an object as balances, got: <%#v>", balancesRaw)) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
iBalances := interpreter.Balances{} | ||||||||||||||
for account, assetsRaw := range balances { | ||||||||||||||
if iBalances[account] == nil { | ||||||||||||||
iBalances[account] = interpreter.AccountBalance{} | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
assets, ok := assetsRaw.(map[string]any) | ||||||||||||||
if !ok { | ||||||||||||||
return interpreter.Balances{}, mcp.NewToolResultError(fmt.Sprintf("Expected nested object for account %v", account)) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
for asset, amountRaw := range assets { | ||||||||||||||
amount, ok := amountRaw.(float64) | ||||||||||||||
if !ok { | ||||||||||||||
return interpreter.Balances{}, mcp.NewToolResultError(fmt.Sprintf("Expected float for amount: %v", amountRaw)) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
n, _ := big.NewFloat(amount).Int(new(big.Int)) | ||||||||||||||
iBalances[account][asset] = n | ||||||||||||||
} | ||||||||||||||
} | ||||||||||||||
return iBalances, nil | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
func parseVarsJson(varsRaw any) (map[string]string, *mcp.CallToolResult) { | ||||||||||||||
vars, ok := varsRaw.(map[string]any) | ||||||||||||||
if !ok { | ||||||||||||||
return map[string]string{}, mcp.NewToolResultError(fmt.Sprintf("Expected an object as vars, got: <%#v>", varsRaw)) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
iVars := map[string]string{} | ||||||||||||||
for key, rawValue := range vars { | ||||||||||||||
|
||||||||||||||
value, ok := rawValue.(string) | ||||||||||||||
if !ok { | ||||||||||||||
return map[string]string{}, mcp.NewToolResultError(fmt.Sprintf("Expected stringified var, got: %v", key)) | ||||||||||||||
} | ||||||||||||||
Comment on lines
+55
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix error message: report the offending value/type, not the key. - value, ok := rawValue.(string)
- if !ok {
- return map[string]string{}, mcp.NewToolResultError(fmt.Sprintf("Expected stringified var, got: %v", key))
- }
+ value, ok := rawValue.(string)
+ if !ok {
+ return map[string]string{}, mcp.NewToolResultError(fmt.Sprintf("Variable %q must be a string, got: %T", key, rawValue))
+ } 🤖 Prompt for AI Agents
|
||||||||||||||
|
||||||||||||||
iVars[key] = value | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
return iVars, nil | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
func addEvalTool(s *server.MCPServer) { | ||||||||||||||
tool := mcp.NewTool("evaluate", | ||||||||||||||
mcp.WithDescription("Evaluate a numscript program"), | ||||||||||||||
mcp.WithIdempotentHintAnnotation(true), | ||||||||||||||
mcp.WithReadOnlyHintAnnotation(true), | ||||||||||||||
mcp.WithOpenWorldHintAnnotation(false), | ||||||||||||||
mcp.WithString("script", | ||||||||||||||
mcp.Required(), | ||||||||||||||
mcp.Description("The numscript source"), | ||||||||||||||
), | ||||||||||||||
mcp.WithObject("balances", | ||||||||||||||
mcp.Required(), | ||||||||||||||
mcp.Description(`The accounts' balances. A nested map from the account name, to the asset, to its integer amount. | ||||||||||||||
For example: { "alice": { "USD/2": 100, "EUR/2": -42 }, "bob": { "BTC": 1 } } | ||||||||||||||
`), | ||||||||||||||
), | ||||||||||||||
mcp.WithObject("vars", | ||||||||||||||
mcp.Required(), | ||||||||||||||
mcp.Description(`The stringified variables to be passed to the script's "vars" block. | ||||||||||||||
For example: { "acc": "alice", "mon": "EUR 100" } can be passed to the following script: | ||||||||||||||
vars { | ||||||||||||||
monetary $mon | ||||||||||||||
account $acc | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
send $mon ( | ||||||||||||||
source = $acc | ||||||||||||||
destination = @world | ||||||||||||||
) | ||||||||||||||
`), | ||||||||||||||
), | ||||||||||||||
) | ||||||||||||||
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { | ||||||||||||||
script, err := request.RequireString("script") | ||||||||||||||
if err != nil { | ||||||||||||||
return mcp.NewToolResultError(err.Error()), nil | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
parsed := parser.Parse(script) | ||||||||||||||
if len(parsed.Errors) != 0 { | ||||||||||||||
out := make([]string, len(parsed.Errors)) | ||||||||||||||
for index, err := range parsed.Errors { | ||||||||||||||
out[index] = err.Msg | ||||||||||||||
} | ||||||||||||||
mcp.NewToolResultError(strings.Join(out, ", ")) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
balances, mcpErr := parseBalancesJson(request.GetArguments()["balances"]) | ||||||||||||||
if mcpErr != nil { | ||||||||||||||
return mcpErr, nil | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
vars, mcpErr := parseVarsJson(request.GetArguments()["vars"]) | ||||||||||||||
if mcpErr != nil { | ||||||||||||||
return mcpErr, nil | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
out, iErr := interpreter.RunProgram( | ||||||||||||||
ctx, | ||||||||||||||
parsed.Value, | ||||||||||||||
vars, | ||||||||||||||
interpreter.StaticStore{ | ||||||||||||||
Balances: balances, | ||||||||||||||
}, | ||||||||||||||
map[string]struct{}{}, | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we'll add feature flags in future releases |
||||||||||||||
) | ||||||||||||||
if iErr != nil { | ||||||||||||||
mcp.NewToolResultError(iErr.Error()) | ||||||||||||||
} | ||||||||||||||
return mcp.NewToolResultJSON(*out) | ||||||||||||||
}) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
func addCheckTool(s *server.MCPServer) { | ||||||||||||||
tool := mcp.NewTool("check", | ||||||||||||||
mcp.WithDescription("Check a program for parsing error or static analysis errors"), | ||||||||||||||
mcp.WithIdempotentHintAnnotation(true), | ||||||||||||||
mcp.WithReadOnlyHintAnnotation(true), | ||||||||||||||
mcp.WithOpenWorldHintAnnotation(false), | ||||||||||||||
mcp.WithString("script", | ||||||||||||||
mcp.Required(), | ||||||||||||||
mcp.Description("The numscript source"), | ||||||||||||||
), | ||||||||||||||
) | ||||||||||||||
|
||||||||||||||
s.AddTool(tool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { | ||||||||||||||
script, err := request.RequireString("script") | ||||||||||||||
if err != nil { | ||||||||||||||
return mcp.NewToolResultError(err.Error()), nil | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
checkResult := analysis.CheckSource(script) | ||||||||||||||
|
||||||||||||||
var errors []any | ||||||||||||||
for _, d := range checkResult.Diagnostics { | ||||||||||||||
errors = append(errors, map[string]any{ | ||||||||||||||
"kind": d.Kind.Message(), | ||||||||||||||
"severity": analysis.SeverityToString(d.Kind.Severity()), | ||||||||||||||
"span": d.Range, | ||||||||||||||
}) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
return mcp.NewToolResultJSON(map[string]any{ | ||||||||||||||
"errors": errors, | ||||||||||||||
}) | ||||||||||||||
}) | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
func RunServer() error { | ||||||||||||||
// Create a new MCP server | ||||||||||||||
s := server.NewMCPServer( | ||||||||||||||
"Numscript", | ||||||||||||||
"0.0.1", | ||||||||||||||
server.WithToolCapabilities(false), | ||||||||||||||
server.WithRecovery(), | ||||||||||||||
server.WithInstructions(` | ||||||||||||||
You're a numscript expert AI assistant. Numscript is a DSL that allows modelling finantial transaction in an easy and declarative way. Numscript scripts alwasy terminate. | ||||||||||||||
`), | ||||||||||||||
Comment on lines
+181
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typos in instructions string. Correct spelling/grammar to avoid user-facing nits: - server.WithInstructions(`
- You're a numscript expert AI assistant. Numscript is a DSL that allows modelling finantial transaction in an easy and declarative way. Numscript scripts alwasy terminate.
- `),
+ server.WithInstructions(`
+ 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.
+ `), 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||
// TODO add prompt | ||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can add a more complete prompt in future releases |
||||||||||||||
) | ||||||||||||||
addEvalTool(s) | ||||||||||||||
addCheckTool(s) | ||||||||||||||
|
||||||||||||||
// Start the server | ||||||||||||||
if err := server.ServeStdio(s); err != nil { | ||||||||||||||
return err | ||||||||||||||
} | ||||||||||||||
|
||||||||||||||
return nil | ||||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Amounts parsing risks precision loss and silently truncates fractions. Enforce integer semantics and accept safe types.
Apply:
Additionally, add import: