From 8cc8714e0bc811f1faf861ee8e051bcef56cc0f6 Mon Sep 17 00:00:00 2001 From: Grant Linville Date: Wed, 4 Feb 2026 23:08:11 -0500 Subject: [PATCH 1/2] chore: bump dependencies Signed-off-by: Grant Linville --- go.mod | 61 +++++----- go.sum | 115 ++++++++++-------- pkg/loader/openapi.go | 2 +- pkg/mcp/loader.go | 2 +- pkg/parser/parser.go | 2 +- pkg/system/prompt.go | 2 +- pkg/tests/runner2_test.go | 43 +++---- .../testdata/TestSysContext/call1.golden | 2 +- .../testdata/TestSysContext/context.json | 2 +- .../testdata/TestSysContext/step1.golden | 2 +- pkg/types/completion.go | 2 +- pkg/types/jsonschema.go | 2 +- pkg/types/tool.go | 2 +- 13 files changed, 130 insertions(+), 109 deletions(-) diff --git a/go.mod b/go.mod index c51f6b77..fd70b585 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/fatih/color v1.18.0 github.com/getkin/kin-openapi v0.132.0 github.com/go-git/go-git/v5 v5.16.3 + github.com/google/jsonschema-go v0.4.2 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/gptscript-ai/broadcaster v0.0.0-20240625175512-c43682019b86 @@ -23,24 +24,23 @@ require ( github.com/hexops/valast v1.5.0 github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 github.com/mholt/archives v0.1.5 - github.com/modelcontextprotocol/go-sdk v0.2.0 - github.com/nanobot-ai/nanobot v0.0.6-0.20250825141756-f61b8b0f41f8 + github.com/nanobot-ai/nanobot v0.0.52 github.com/pkoukk/tiktoken-go v0.1.7 github.com/pkoukk/tiktoken-go-loader v0.0.2-0.20240522064338-c17e8bc0f699 github.com/rs/cors v1.11.0 github.com/samber/lo v1.38.1 github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cobra v1.9.1 - github.com/spf13/pflag v1.0.6 - github.com/stretchr/testify v1.10.0 - github.com/tidwall/gjson v1.17.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.18.0 github.com/xeipuuv/gojsonschema v1.2.0 - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 - golang.org/x/sync v0.18.0 - golang.org/x/term v0.37.0 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 + golang.org/x/sync v0.19.0 + golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 - sigs.k8s.io/yaml v1.4.0 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -69,19 +69,20 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/creack/pty v1.1.24 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dlclark/regexp2 v1.11.4 // indirect - github.com/dop251/goja v0.0.0-20250531102226-cb187b08699c // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect github.com/gookit/color v1.5.4 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -95,7 +96,7 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -104,6 +105,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.26 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect + github.com/modelcontextprotocol/go-sdk v0.2.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -111,37 +113,40 @@ require ( github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/obot-platform/mcp-oauth-proxy v0.0.3-0.20260106135339-3745d9b14a30 // indirect github.com/olekukonko/tablewriter v0.0.6-0.20230925090304-df64c4bbad77 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pterm/pterm v0.12.79 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sergi/go-diff v1.4.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e // indirect github.com/spf13/afero v1.15.0 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.5.4 // indirect + github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.41.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect mvdan.cc/gofumpt v0.8.0 // indirect ) diff --git a/go.sum b/go.sum index 7f08e416..3ffbeb7b 100644 --- a/go.sum +++ b/go.sum @@ -113,16 +113,17 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= -github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v26.0.0+incompatible h1:90BKrx1a1HKYpSnnBFR6AgDq/FqkHxwlUyzJVPxD30I= github.com/docker/cli v26.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo= github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M= -github.com/dop251/goja v0.0.0-20250531102226-cb187b08699c h1:In87uFQZsuGfjDDNfWnzMVY6JVTwc8XYMl6W2DAmNjk= -github.com/dop251/goja v0.0.0-20250531102226-cb187b08699c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= +github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= @@ -156,10 +157,12 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= -github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= +github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -187,12 +190,14 @@ github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= -github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef h1:xpF9fUHpoIrrjX24DURVKiwHcFpw19ndIs+FwTSMbno= +github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= @@ -270,8 +275,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -308,8 +313,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/nanobot-ai/nanobot v0.0.6-0.20250825141756-f61b8b0f41f8 h1:SZsity7OCSBRVnqfPMpmaSnaIFlMUm3z8sGED5C31XU= -github.com/nanobot-ai/nanobot v0.0.6-0.20250825141756-f61b8b0f41f8/go.mod h1:vKoxU5Fro4DuvHq2AsxjhNYF3/KRlAuHLFT+NZ9ns5w= +github.com/nanobot-ai/nanobot v0.0.52 h1:Jf3VtKljPcJBS2tZIxzcx3u5mJmrgn9HQ54/NQQ9JOo= +github.com/nanobot-ai/nanobot v0.0.52/go.mod h1:Es0FimWKLR9KtK2qi1MU7GfAD/g6PLQjbkAv/3PnzBc= github.com/nightlyone/lockfile v1.0.0 h1:RHep2cFKK4PonZJDdEl4GmkabuhbsRMgk/k3uAmxBiA= github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatROs6LzC841CI= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= @@ -318,6 +323,8 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/obot-platform/mcp-oauth-proxy v0.0.3-0.20260106135339-3745d9b14a30 h1:PbvvLXjoUHQGpQ4ZzV9Aj8n8y0evpZljPlpDu96lFC4= +github.com/obot-platform/mcp-oauth-proxy v0.0.3-0.20260106135339-3745d9b14a30/go.mod h1:8I+MeGRPsv42hk7/MCSqWHvz4p7xvIR0CIh1GG3vtXM= github.com/olekukonko/tablewriter v0.0.6-0.20230925090304-df64c4bbad77 h1:3bMMZ1f+GPXFQ1uNaYbO/uECWvSfqEA+ZEXn1rFAT88= github.com/olekukonko/tablewriter v0.0.6-0.20230925090304-df64c4bbad77/go.mod h1:8Hf+pH6thup1sPZPD+NLg7d6vbpsdilu9CPIeikvgMQ= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= @@ -335,8 +342,9 @@ github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQ github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pkoukk/tiktoken-go-loader v0.0.2-0.20240522064338-c17e8bc0f699 h1:Sp8yiuxsitkmCfEvUnmNf8wzuZwlGNkRjI2yF0C3QUQ= github.com/pkoukk/tiktoken-go-loader v0.0.2-0.20240522064338-c17e8bc0f699/go.mod h1:4mIkYyZooFlnenDlormIo6cd5wrlUKNr97wp9nGgEKo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= @@ -364,8 +372,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0 github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= -github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -377,10 +385,11 @@ github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e h1:H+jDT github.com/sourcegraph/go-diff-patch v0.0.0-20240223163233-798fd1e94a8e/go.mod h1:VsUklG6OQo7Ctunu0gS3AtEOCEc2kMB6r5rKzxAes58= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -396,14 +405,16 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= -github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= @@ -424,14 +435,18 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= -github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc= go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -440,8 +455,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -450,8 +465,8 @@ golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -472,8 +487,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -493,15 +508,15 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -511,8 +526,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -547,16 +562,16 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -568,8 +583,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -599,8 +614,8 @@ golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -666,5 +681,5 @@ mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= -sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/pkg/loader/openapi.go b/pkg/loader/openapi.go index ce8c5dc6..7c08a54d 100644 --- a/pkg/loader/openapi.go +++ b/pkg/loader/openapi.go @@ -14,7 +14,7 @@ import ( "github.com/getkin/kin-openapi/openapi3" "github.com/gptscript-ai/gptscript/pkg/openapi" "github.com/gptscript-ai/gptscript/pkg/types" - "github.com/modelcontextprotocol/go-sdk/jsonschema" + "github.com/google/jsonschema-go/jsonschema" ) var toolNameRegex = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) diff --git a/pkg/mcp/loader.go b/pkg/mcp/loader.go index 7ec6fb5c..efef3c2b 100644 --- a/pkg/mcp/loader.go +++ b/pkg/mcp/loader.go @@ -13,7 +13,7 @@ import ( "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/mvl" "github.com/gptscript-ai/gptscript/pkg/types" - "github.com/modelcontextprotocol/go-sdk/jsonschema" + "github.com/google/jsonschema-go/jsonschema" nmcp "github.com/nanobot-ai/nanobot/pkg/mcp" ) diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 3d26d9cc..2b625c90 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -11,7 +11,7 @@ import ( "strings" "github.com/gptscript-ai/gptscript/pkg/types" - "github.com/modelcontextprotocol/go-sdk/jsonschema" + "github.com/google/jsonschema-go/jsonschema" ) var ( diff --git a/pkg/system/prompt.go b/pkg/system/prompt.go index 04497854..8b2022b0 100644 --- a/pkg/system/prompt.go +++ b/pkg/system/prompt.go @@ -5,7 +5,7 @@ import ( "os" "strings" - "github.com/modelcontextprotocol/go-sdk/jsonschema" + "github.com/google/jsonschema-go/jsonschema" ) // Suffix is default suffix of gptscript files diff --git a/pkg/tests/runner2_test.go b/pkg/tests/runner2_test.go index 9668a98a..c3c0eabd 100644 --- a/pkg/tests/runner2_test.go +++ b/pkg/tests/runner2_test.go @@ -303,15 +303,15 @@ name: mcp "internalPrompt": null, "arguments": { "type": "object", - "required": [ - "insight" - ], "properties": { "insight": { "type": "string", "description": "Business insight discovered from data analysis" } - } + }, + "required": [ + "insight" + ] }, "instructions": "#!sys.mcp.invoke.append_insight e592cc0c9483290685611ba70bd8595829cc794f7eae0419eabb3388bf0d3529", "id": "inline:append_insight", @@ -336,15 +336,15 @@ name: mcp "internalPrompt": null, "arguments": { "type": "object", - "required": [ - "query" - ], "properties": { "query": { "type": "string", "description": "CREATE TABLE SQL statement" } - } + }, + "required": [ + "query" + ] }, "instructions": "#!sys.mcp.invoke.create_table e592cc0c9483290685611ba70bd8595829cc794f7eae0419eabb3388bf0d3529", "id": "inline:create_table", @@ -369,15 +369,15 @@ name: mcp "internalPrompt": null, "arguments": { "type": "object", - "required": [ - "table_name" - ], "properties": { "table_name": { "type": "string", "description": "Name of the table to describe" } - } + }, + "required": [ + "table_name" + ] }, "instructions": "#!sys.mcp.invoke.describe_table e592cc0c9483290685611ba70bd8595829cc794f7eae0419eabb3388bf0d3529", "id": "inline:describe_table", @@ -401,7 +401,8 @@ name: mcp "modelName": "gpt-4o", "internalPrompt": null, "arguments": { - "type": "object" + "type": "object", + "properties": {} }, "instructions": "#!sys.mcp.invoke.list_tables e592cc0c9483290685611ba70bd8595829cc794f7eae0419eabb3388bf0d3529", "id": "inline:list_tables", @@ -495,15 +496,15 @@ name: mcp "internalPrompt": null, "arguments": { "type": "object", - "required": [ - "query" - ], "properties": { "query": { "type": "string", "description": "SELECT SQL query to execute" } - } + }, + "required": [ + "query" + ] }, "instructions": "#!sys.mcp.invoke.read_query e592cc0c9483290685611ba70bd8595829cc794f7eae0419eabb3388bf0d3529", "id": "inline:read_query", @@ -528,15 +529,15 @@ name: mcp "internalPrompt": null, "arguments": { "type": "object", - "required": [ - "query" - ], "properties": { "query": { "type": "string", "description": "SQL query to execute" } - } + }, + "required": [ + "query" + ] }, "instructions": "#!sys.mcp.invoke.write_query e592cc0c9483290685611ba70bd8595829cc794f7eae0419eabb3388bf0d3529", "id": "inline:write_query", diff --git a/pkg/tests/testdata/TestSysContext/call1.golden b/pkg/tests/testdata/TestSysContext/call1.golden index f1926c33..713bf07b 100644 --- a/pkg/tests/testdata/TestSysContext/call1.golden +++ b/pkg/tests/testdata/TestSysContext/call1.golden @@ -23,7 +23,7 @@ "role": "system", "content": [ { - "text": "{\"call\":{\"id\":\"\",\"tool\":{\"name\":\"sys.context\",\"description\":\"Retrieves the current internal GPTScript tool call context information\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"arguments\":{\"type\":\"object\"},\"instructions\":\"#!sys.context\",\"id\":\"sys.context\",\"source\":{}},\"currentAgent\":{},\"agentGroup\":[{\"named\":\"iAmSuperman\",\"reference\":\"./file.gpt\",\"toolID\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"}],\"inputContext\":null,\"toolCategory\":\"context\",\"toolName\":\"sys.context\"},\"program\":{\"name\":\"testdata/TestSysContext/test.gpt\",\"entryToolId\":\"testdata/TestSysContext/test.gpt:\",\"toolSet\":{\"sys.context\":{\"name\":\"sys.context\",\"description\":\"Retrieves the current internal GPTScript tool call context information\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"arguments\":{\"type\":\"object\"},\"instructions\":\"#!sys.context\",\"id\":\"sys.context\",\"source\":{}},\"testdata/TestSysContext/file.gpt:I am Superman Agent\":{\"name\":\"I am Superman Agent\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"instructions\":\"I'm super\",\"id\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\",\"localTools\":{\"i am superman agent\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"},\"source\":{\"location\":\"testdata/TestSysContext/file.gpt\",\"lineNo\":1},\"workingDir\":\"testdata/TestSysContext\"},\"testdata/TestSysContext/test.gpt:\":{\"modelName\":\"gpt-4o\",\"chat\":true,\"internalPrompt\":null,\"context\":[\"agents\"],\"agents\":[\"./file.gpt\"],\"instructions\":\"Tool body\",\"id\":\"testdata/TestSysContext/test.gpt:\",\"toolMapping\":{\"./file.gpt\":[{\"reference\":\"./file.gpt\",\"toolID\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"}],\"agents\":[{\"reference\":\"agents\",\"toolID\":\"testdata/TestSysContext/test.gpt:agents\"}]},\"localTools\":{\"\":\"testdata/TestSysContext/test.gpt:\",\"agents\":\"testdata/TestSysContext/test.gpt:agents\"},\"source\":{\"location\":\"testdata/TestSysContext/test.gpt\",\"lineNo\":1},\"workingDir\":\"testdata/TestSysContext\"},\"testdata/TestSysContext/test.gpt:agents\":{\"name\":\"agents\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"context\":[\"sys.context\"],\"instructions\":\"#!/bin/bash\\n\\necho \\\"${GPTSCRIPT_CONTEXT}\\\"\\necho \\\"${GPTSCRIPT_CONTEXT}\\\" \\u003e ${GPTSCRIPT_TOOL_DIR}/context.json\",\"id\":\"testdata/TestSysContext/test.gpt:agents\",\"toolMapping\":{\"sys.context\":[{\"reference\":\"sys.context\",\"toolID\":\"sys.context\"}]},\"localTools\":{\"\":\"testdata/TestSysContext/test.gpt:\",\"agents\":\"testdata/TestSysContext/test.gpt:agents\"},\"source\":{\"location\":\"testdata/TestSysContext/test.gpt\",\"lineNo\":8},\"workingDir\":\"testdata/TestSysContext\"}}}}\n\nTool body" + "text": "{\"call\":{\"id\":\"\",\"tool\":{\"name\":\"sys.context\",\"description\":\"Retrieves the current internal GPTScript tool call context information\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"arguments\":{\"type\":\"object\",\"properties\":{}},\"instructions\":\"#!sys.context\",\"id\":\"sys.context\",\"source\":{}},\"currentAgent\":{},\"agentGroup\":[{\"named\":\"iAmSuperman\",\"reference\":\"./file.gpt\",\"toolID\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"}],\"inputContext\":null,\"toolCategory\":\"context\",\"toolName\":\"sys.context\"},\"program\":{\"name\":\"testdata/TestSysContext/test.gpt\",\"entryToolId\":\"testdata/TestSysContext/test.gpt:\",\"toolSet\":{\"sys.context\":{\"name\":\"sys.context\",\"description\":\"Retrieves the current internal GPTScript tool call context information\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"arguments\":{\"type\":\"object\",\"properties\":{}},\"instructions\":\"#!sys.context\",\"id\":\"sys.context\",\"source\":{}},\"testdata/TestSysContext/file.gpt:I am Superman Agent\":{\"name\":\"I am Superman Agent\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"instructions\":\"I'm super\",\"id\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\",\"localTools\":{\"i am superman agent\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"},\"source\":{\"location\":\"testdata/TestSysContext/file.gpt\",\"lineNo\":1},\"workingDir\":\"testdata/TestSysContext\"},\"testdata/TestSysContext/test.gpt:\":{\"modelName\":\"gpt-4o\",\"chat\":true,\"internalPrompt\":null,\"context\":[\"agents\"],\"agents\":[\"./file.gpt\"],\"instructions\":\"Tool body\",\"id\":\"testdata/TestSysContext/test.gpt:\",\"toolMapping\":{\"./file.gpt\":[{\"reference\":\"./file.gpt\",\"toolID\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"}],\"agents\":[{\"reference\":\"agents\",\"toolID\":\"testdata/TestSysContext/test.gpt:agents\"}]},\"localTools\":{\"\":\"testdata/TestSysContext/test.gpt:\",\"agents\":\"testdata/TestSysContext/test.gpt:agents\"},\"source\":{\"location\":\"testdata/TestSysContext/test.gpt\",\"lineNo\":1},\"workingDir\":\"testdata/TestSysContext\"},\"testdata/TestSysContext/test.gpt:agents\":{\"name\":\"agents\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"context\":[\"sys.context\"],\"instructions\":\"#!/bin/bash\\n\\necho \\\"${GPTSCRIPT_CONTEXT}\\\"\\necho \\\"${GPTSCRIPT_CONTEXT}\\\" \\u003e ${GPTSCRIPT_TOOL_DIR}/context.json\",\"id\":\"testdata/TestSysContext/test.gpt:agents\",\"toolMapping\":{\"sys.context\":[{\"reference\":\"sys.context\",\"toolID\":\"sys.context\"}]},\"localTools\":{\"\":\"testdata/TestSysContext/test.gpt:\",\"agents\":\"testdata/TestSysContext/test.gpt:agents\"},\"source\":{\"location\":\"testdata/TestSysContext/test.gpt\",\"lineNo\":8},\"workingDir\":\"testdata/TestSysContext\"}}}}\n\nTool body" } ], "usage": {} diff --git a/pkg/tests/testdata/TestSysContext/context.json b/pkg/tests/testdata/TestSysContext/context.json index b6d62218..7264ecb0 100644 --- a/pkg/tests/testdata/TestSysContext/context.json +++ b/pkg/tests/testdata/TestSysContext/context.json @@ -1 +1 @@ -{"call":{"id":"","tool":{"name":"sys.context","description":"Retrieves the current internal GPTScript tool call context information","modelName":"gpt-4o","internalPrompt":null,"arguments":{"type":"object"},"instructions":"#!sys.context","id":"sys.context","source":{}},"currentAgent":{},"agentGroup":[{"named":"iAmSuperman","reference":"./file.gpt","toolID":"testdata/TestSysContext/file.gpt:I am Superman Agent"}],"inputContext":null,"toolCategory":"context","toolName":"sys.context"},"program":{"name":"testdata/TestSysContext/test.gpt","entryToolId":"testdata/TestSysContext/test.gpt:","toolSet":{"sys.context":{"name":"sys.context","description":"Retrieves the current internal GPTScript tool call context information","modelName":"gpt-4o","internalPrompt":null,"arguments":{"type":"object"},"instructions":"#!sys.context","id":"sys.context","source":{}},"testdata/TestSysContext/file.gpt:I am Superman Agent":{"name":"I am Superman Agent","modelName":"gpt-4o","internalPrompt":null,"instructions":"I'm super","id":"testdata/TestSysContext/file.gpt:I am Superman Agent","localTools":{"i am superman agent":"testdata/TestSysContext/file.gpt:I am Superman Agent"},"source":{"location":"testdata/TestSysContext/file.gpt","lineNo":1},"workingDir":"testdata/TestSysContext"},"testdata/TestSysContext/test.gpt:":{"modelName":"gpt-4o","chat":true,"internalPrompt":null,"context":["agents"],"agents":["./file.gpt"],"instructions":"Tool body","id":"testdata/TestSysContext/test.gpt:","toolMapping":{"./file.gpt":[{"reference":"./file.gpt","toolID":"testdata/TestSysContext/file.gpt:I am Superman Agent"}],"agents":[{"reference":"agents","toolID":"testdata/TestSysContext/test.gpt:agents"}]},"localTools":{"":"testdata/TestSysContext/test.gpt:","agents":"testdata/TestSysContext/test.gpt:agents"},"source":{"location":"testdata/TestSysContext/test.gpt","lineNo":1},"workingDir":"testdata/TestSysContext"},"testdata/TestSysContext/test.gpt:agents":{"name":"agents","modelName":"gpt-4o","internalPrompt":null,"context":["sys.context"],"instructions":"#!/bin/bash\n\necho \"${GPTSCRIPT_CONTEXT}\"\necho \"${GPTSCRIPT_CONTEXT}\" \u003e ${GPTSCRIPT_TOOL_DIR}/context.json","id":"testdata/TestSysContext/test.gpt:agents","toolMapping":{"sys.context":[{"reference":"sys.context","toolID":"sys.context"}]},"localTools":{"":"testdata/TestSysContext/test.gpt:","agents":"testdata/TestSysContext/test.gpt:agents"},"source":{"location":"testdata/TestSysContext/test.gpt","lineNo":8},"workingDir":"testdata/TestSysContext"}}}} +{"call":{"id":"","tool":{"name":"sys.context","description":"Retrieves the current internal GPTScript tool call context information","modelName":"gpt-4o","internalPrompt":null,"arguments":{"type":"object","properties":{}},"instructions":"#!sys.context","id":"sys.context","source":{}},"currentAgent":{},"agentGroup":[{"named":"iAmSuperman","reference":"./file.gpt","toolID":"testdata/TestSysContext/file.gpt:I am Superman Agent"}],"inputContext":null,"toolCategory":"context","toolName":"sys.context"},"program":{"name":"testdata/TestSysContext/test.gpt","entryToolId":"testdata/TestSysContext/test.gpt:","toolSet":{"sys.context":{"name":"sys.context","description":"Retrieves the current internal GPTScript tool call context information","modelName":"gpt-4o","internalPrompt":null,"arguments":{"type":"object","properties":{}},"instructions":"#!sys.context","id":"sys.context","source":{}},"testdata/TestSysContext/file.gpt:I am Superman Agent":{"name":"I am Superman Agent","modelName":"gpt-4o","internalPrompt":null,"instructions":"I'm super","id":"testdata/TestSysContext/file.gpt:I am Superman Agent","localTools":{"i am superman agent":"testdata/TestSysContext/file.gpt:I am Superman Agent"},"source":{"location":"testdata/TestSysContext/file.gpt","lineNo":1},"workingDir":"testdata/TestSysContext"},"testdata/TestSysContext/test.gpt:":{"modelName":"gpt-4o","chat":true,"internalPrompt":null,"context":["agents"],"agents":["./file.gpt"],"instructions":"Tool body","id":"testdata/TestSysContext/test.gpt:","toolMapping":{"./file.gpt":[{"reference":"./file.gpt","toolID":"testdata/TestSysContext/file.gpt:I am Superman Agent"}],"agents":[{"reference":"agents","toolID":"testdata/TestSysContext/test.gpt:agents"}]},"localTools":{"":"testdata/TestSysContext/test.gpt:","agents":"testdata/TestSysContext/test.gpt:agents"},"source":{"location":"testdata/TestSysContext/test.gpt","lineNo":1},"workingDir":"testdata/TestSysContext"},"testdata/TestSysContext/test.gpt:agents":{"name":"agents","modelName":"gpt-4o","internalPrompt":null,"context":["sys.context"],"instructions":"#!/bin/bash\n\necho \"${GPTSCRIPT_CONTEXT}\"\necho \"${GPTSCRIPT_CONTEXT}\" \u003e ${GPTSCRIPT_TOOL_DIR}/context.json","id":"testdata/TestSysContext/test.gpt:agents","toolMapping":{"sys.context":[{"reference":"sys.context","toolID":"sys.context"}]},"localTools":{"":"testdata/TestSysContext/test.gpt:","agents":"testdata/TestSysContext/test.gpt:agents"},"source":{"location":"testdata/TestSysContext/test.gpt","lineNo":8},"workingDir":"testdata/TestSysContext"}}}} diff --git a/pkg/tests/testdata/TestSysContext/step1.golden b/pkg/tests/testdata/TestSysContext/step1.golden index 2755652a..11309692 100644 --- a/pkg/tests/testdata/TestSysContext/step1.golden +++ b/pkg/tests/testdata/TestSysContext/step1.golden @@ -31,7 +31,7 @@ "role": "system", "content": [ { - "text": "{\"call\":{\"id\":\"\",\"tool\":{\"name\":\"sys.context\",\"description\":\"Retrieves the current internal GPTScript tool call context information\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"arguments\":{\"type\":\"object\"},\"instructions\":\"#!sys.context\",\"id\":\"sys.context\",\"source\":{}},\"currentAgent\":{},\"agentGroup\":[{\"named\":\"iAmSuperman\",\"reference\":\"./file.gpt\",\"toolID\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"}],\"inputContext\":null,\"toolCategory\":\"context\",\"toolName\":\"sys.context\"},\"program\":{\"name\":\"testdata/TestSysContext/test.gpt\",\"entryToolId\":\"testdata/TestSysContext/test.gpt:\",\"toolSet\":{\"sys.context\":{\"name\":\"sys.context\",\"description\":\"Retrieves the current internal GPTScript tool call context information\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"arguments\":{\"type\":\"object\"},\"instructions\":\"#!sys.context\",\"id\":\"sys.context\",\"source\":{}},\"testdata/TestSysContext/file.gpt:I am Superman Agent\":{\"name\":\"I am Superman Agent\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"instructions\":\"I'm super\",\"id\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\",\"localTools\":{\"i am superman agent\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"},\"source\":{\"location\":\"testdata/TestSysContext/file.gpt\",\"lineNo\":1},\"workingDir\":\"testdata/TestSysContext\"},\"testdata/TestSysContext/test.gpt:\":{\"modelName\":\"gpt-4o\",\"chat\":true,\"internalPrompt\":null,\"context\":[\"agents\"],\"agents\":[\"./file.gpt\"],\"instructions\":\"Tool body\",\"id\":\"testdata/TestSysContext/test.gpt:\",\"toolMapping\":{\"./file.gpt\":[{\"reference\":\"./file.gpt\",\"toolID\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"}],\"agents\":[{\"reference\":\"agents\",\"toolID\":\"testdata/TestSysContext/test.gpt:agents\"}]},\"localTools\":{\"\":\"testdata/TestSysContext/test.gpt:\",\"agents\":\"testdata/TestSysContext/test.gpt:agents\"},\"source\":{\"location\":\"testdata/TestSysContext/test.gpt\",\"lineNo\":1},\"workingDir\":\"testdata/TestSysContext\"},\"testdata/TestSysContext/test.gpt:agents\":{\"name\":\"agents\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"context\":[\"sys.context\"],\"instructions\":\"#!/bin/bash\\n\\necho \\\"${GPTSCRIPT_CONTEXT}\\\"\\necho \\\"${GPTSCRIPT_CONTEXT}\\\" \\u003e ${GPTSCRIPT_TOOL_DIR}/context.json\",\"id\":\"testdata/TestSysContext/test.gpt:agents\",\"toolMapping\":{\"sys.context\":[{\"reference\":\"sys.context\",\"toolID\":\"sys.context\"}]},\"localTools\":{\"\":\"testdata/TestSysContext/test.gpt:\",\"agents\":\"testdata/TestSysContext/test.gpt:agents\"},\"source\":{\"location\":\"testdata/TestSysContext/test.gpt\",\"lineNo\":8},\"workingDir\":\"testdata/TestSysContext\"}}}}\n\nTool body" + "text": "{\"call\":{\"id\":\"\",\"tool\":{\"name\":\"sys.context\",\"description\":\"Retrieves the current internal GPTScript tool call context information\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"arguments\":{\"type\":\"object\",\"properties\":{}},\"instructions\":\"#!sys.context\",\"id\":\"sys.context\",\"source\":{}},\"currentAgent\":{},\"agentGroup\":[{\"named\":\"iAmSuperman\",\"reference\":\"./file.gpt\",\"toolID\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"}],\"inputContext\":null,\"toolCategory\":\"context\",\"toolName\":\"sys.context\"},\"program\":{\"name\":\"testdata/TestSysContext/test.gpt\",\"entryToolId\":\"testdata/TestSysContext/test.gpt:\",\"toolSet\":{\"sys.context\":{\"name\":\"sys.context\",\"description\":\"Retrieves the current internal GPTScript tool call context information\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"arguments\":{\"type\":\"object\",\"properties\":{}},\"instructions\":\"#!sys.context\",\"id\":\"sys.context\",\"source\":{}},\"testdata/TestSysContext/file.gpt:I am Superman Agent\":{\"name\":\"I am Superman Agent\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"instructions\":\"I'm super\",\"id\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\",\"localTools\":{\"i am superman agent\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"},\"source\":{\"location\":\"testdata/TestSysContext/file.gpt\",\"lineNo\":1},\"workingDir\":\"testdata/TestSysContext\"},\"testdata/TestSysContext/test.gpt:\":{\"modelName\":\"gpt-4o\",\"chat\":true,\"internalPrompt\":null,\"context\":[\"agents\"],\"agents\":[\"./file.gpt\"],\"instructions\":\"Tool body\",\"id\":\"testdata/TestSysContext/test.gpt:\",\"toolMapping\":{\"./file.gpt\":[{\"reference\":\"./file.gpt\",\"toolID\":\"testdata/TestSysContext/file.gpt:I am Superman Agent\"}],\"agents\":[{\"reference\":\"agents\",\"toolID\":\"testdata/TestSysContext/test.gpt:agents\"}]},\"localTools\":{\"\":\"testdata/TestSysContext/test.gpt:\",\"agents\":\"testdata/TestSysContext/test.gpt:agents\"},\"source\":{\"location\":\"testdata/TestSysContext/test.gpt\",\"lineNo\":1},\"workingDir\":\"testdata/TestSysContext\"},\"testdata/TestSysContext/test.gpt:agents\":{\"name\":\"agents\",\"modelName\":\"gpt-4o\",\"internalPrompt\":null,\"context\":[\"sys.context\"],\"instructions\":\"#!/bin/bash\\n\\necho \\\"${GPTSCRIPT_CONTEXT}\\\"\\necho \\\"${GPTSCRIPT_CONTEXT}\\\" \\u003e ${GPTSCRIPT_TOOL_DIR}/context.json\",\"id\":\"testdata/TestSysContext/test.gpt:agents\",\"toolMapping\":{\"sys.context\":[{\"reference\":\"sys.context\",\"toolID\":\"sys.context\"}]},\"localTools\":{\"\":\"testdata/TestSysContext/test.gpt:\",\"agents\":\"testdata/TestSysContext/test.gpt:agents\"},\"source\":{\"location\":\"testdata/TestSysContext/test.gpt\",\"lineNo\":8},\"workingDir\":\"testdata/TestSysContext\"}}}}\n\nTool body" } ], "usage": {} diff --git a/pkg/types/completion.go b/pkg/types/completion.go index bacdaab8..23e2eb18 100644 --- a/pkg/types/completion.go +++ b/pkg/types/completion.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/modelcontextprotocol/go-sdk/jsonschema" + "github.com/google/jsonschema-go/jsonschema" ) type CompletionRequest struct { diff --git a/pkg/types/jsonschema.go b/pkg/types/jsonschema.go index 17df0692..56d9943d 100644 --- a/pkg/types/jsonschema.go +++ b/pkg/types/jsonschema.go @@ -1,7 +1,7 @@ //nolint:revive package types -import "github.com/modelcontextprotocol/go-sdk/jsonschema" +import "github.com/google/jsonschema-go/jsonschema" func ObjectSchema(kv ...string) *jsonschema.Schema { s := &jsonschema.Schema{ diff --git a/pkg/types/tool.go b/pkg/types/tool.go index 42b08f62..6972f3f8 100644 --- a/pkg/types/tool.go +++ b/pkg/types/tool.go @@ -12,7 +12,7 @@ import ( "github.com/google/shlex" "github.com/gptscript-ai/gptscript/pkg/system" - "github.com/modelcontextprotocol/go-sdk/jsonschema" + "github.com/google/jsonschema-go/jsonschema" "golang.org/x/exp/maps" ) From 9bc6d06370475c6404d71dd8292d2a5252633b28 Mon Sep 17 00:00:00 2001 From: Grant Linville Date: Thu, 5 Feb 2026 00:11:18 -0500 Subject: [PATCH 2/2] copy in a ton of nanobot code Signed-off-by: Grant Linville --- go.mod | 10 +- go.sum | 10 - pkg/cli/main.go | 2 +- pkg/loader/openapi.go | 2 +- pkg/mcp/client.go | 2 +- pkg/mcp/loader.go | 4 +- pkg/nanobot/complete/complete.go | 56 ++ pkg/nanobot/envvar/replace.go | 44 ++ pkg/nanobot/expr/eval.go | 169 ++++++ pkg/nanobot/expr/expand.go | 69 +++ pkg/nanobot/expr/expand_test.go | 93 +++ pkg/nanobot/expr/lookup.go | 50 ++ pkg/nanobot/log/log.go | 62 ++ pkg/nanobot/mcp/callback.go | 85 +++ pkg/nanobot/mcp/client.go | 460 ++++++++++++++ pkg/nanobot/mcp/clientlookup.go | 43 ++ pkg/nanobot/mcp/error.go | 21 + pkg/nanobot/mcp/httpclient.go | 669 +++++++++++++++++++++ pkg/nanobot/mcp/httpserver.go | 390 ++++++++++++ pkg/nanobot/mcp/message.go | 183 ++++++ pkg/nanobot/mcp/oauth.go | 667 ++++++++++++++++++++ pkg/nanobot/mcp/pendingrequest.go | 53 ++ pkg/nanobot/mcp/runner.go | 255 ++++++++ pkg/nanobot/mcp/sandbox/out.go | 21 + pkg/nanobot/mcp/sandbox/reverseports.go | 102 ++++ pkg/nanobot/mcp/sandbox/sandbox.go | 289 +++++++++ pkg/nanobot/mcp/serversession.go | 247 ++++++++ pkg/nanobot/mcp/servertools.go | 211 +++++++ pkg/nanobot/mcp/session.go | 521 ++++++++++++++++ pkg/nanobot/mcp/sessionstore.go | 44 ++ pkg/nanobot/mcp/stdio.go | 136 +++++ pkg/nanobot/mcp/stdioserver.go | 62 ++ pkg/nanobot/mcp/tokenstorage.go | 88 +++ pkg/nanobot/mcp/types.go | 339 +++++++++++ pkg/nanobot/printer/lineprefix.go | 142 +++++ pkg/nanobot/reverseproxy/server.go | 246 ++++++++ pkg/nanobot/reverseproxy/tlsclient.go | 148 +++++ pkg/nanobot/supervise/daemon.go | 50 ++ pkg/nanobot/supervise/supervise.go | 29 + pkg/nanobot/supervise/supervise_windows.go | 22 + pkg/nanobot/system/currentbin.go | 32 + pkg/nanobot/uuid/uuid.go | 30 + pkg/nanobot/version/version.go | 57 ++ pkg/parser/parser.go | 2 +- pkg/types/tool.go | 2 +- 45 files changed, 6196 insertions(+), 23 deletions(-) create mode 100644 pkg/nanobot/complete/complete.go create mode 100644 pkg/nanobot/envvar/replace.go create mode 100644 pkg/nanobot/expr/eval.go create mode 100644 pkg/nanobot/expr/expand.go create mode 100644 pkg/nanobot/expr/expand_test.go create mode 100644 pkg/nanobot/expr/lookup.go create mode 100644 pkg/nanobot/log/log.go create mode 100644 pkg/nanobot/mcp/callback.go create mode 100644 pkg/nanobot/mcp/client.go create mode 100644 pkg/nanobot/mcp/clientlookup.go create mode 100644 pkg/nanobot/mcp/error.go create mode 100644 pkg/nanobot/mcp/httpclient.go create mode 100644 pkg/nanobot/mcp/httpserver.go create mode 100644 pkg/nanobot/mcp/message.go create mode 100644 pkg/nanobot/mcp/oauth.go create mode 100644 pkg/nanobot/mcp/pendingrequest.go create mode 100644 pkg/nanobot/mcp/runner.go create mode 100644 pkg/nanobot/mcp/sandbox/out.go create mode 100644 pkg/nanobot/mcp/sandbox/reverseports.go create mode 100644 pkg/nanobot/mcp/sandbox/sandbox.go create mode 100644 pkg/nanobot/mcp/serversession.go create mode 100644 pkg/nanobot/mcp/servertools.go create mode 100644 pkg/nanobot/mcp/session.go create mode 100644 pkg/nanobot/mcp/sessionstore.go create mode 100644 pkg/nanobot/mcp/stdio.go create mode 100644 pkg/nanobot/mcp/stdioserver.go create mode 100644 pkg/nanobot/mcp/tokenstorage.go create mode 100644 pkg/nanobot/mcp/types.go create mode 100644 pkg/nanobot/printer/lineprefix.go create mode 100644 pkg/nanobot/reverseproxy/server.go create mode 100644 pkg/nanobot/reverseproxy/tlsclient.go create mode 100644 pkg/nanobot/supervise/daemon.go create mode 100644 pkg/nanobot/supervise/supervise.go create mode 100644 pkg/nanobot/supervise/supervise_windows.go create mode 100644 pkg/nanobot/system/currentbin.go create mode 100644 pkg/nanobot/uuid/uuid.go create mode 100644 pkg/nanobot/version/version.go diff --git a/go.mod b/go.mod index fd70b585..fe7cf7f8 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/gptscript-ai/gptscript go 1.25.5 +replace github.com/gptscript-ai/go-gptscript => ../go-gptscript + require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 @@ -9,6 +11,7 @@ require ( github.com/chzyer/readline v1.5.1 github.com/docker/cli v26.0.0+incompatible github.com/docker/docker-credential-helpers v0.8.1 + github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/fatih/color v1.18.0 github.com/getkin/kin-openapi v0.132.0 github.com/go-git/go-git/v5 v5.16.3 @@ -24,7 +27,6 @@ require ( github.com/hexops/valast v1.5.0 github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 github.com/mholt/archives v0.1.5 - github.com/nanobot-ai/nanobot v0.0.52 github.com/pkoukk/tiktoken-go v0.1.7 github.com/pkoukk/tiktoken-go-loader v0.0.2-0.20240522064338-c17e8bc0f699 github.com/rs/cors v1.11.0 @@ -36,6 +38,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/xeipuuv/gojsonschema v1.2.0 golang.org/x/exp v0.0.0-20260112195511-716be5621a96 + golang.org/x/oauth2 v0.34.0 golang.org/x/sync v0.19.0 golang.org/x/term v0.39.0 gopkg.in/yaml.v3 v3.0.1 @@ -71,7 +74,6 @@ require ( github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect @@ -79,7 +81,6 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260202012954-cb029daf43ef // indirect @@ -105,7 +106,6 @@ require ( github.com/microcosm-cc/bluemonday v1.0.26 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.1 // indirect - github.com/modelcontextprotocol/go-sdk v0.2.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -113,7 +113,6 @@ require ( github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect - github.com/obot-platform/mcp-oauth-proxy v0.0.3-0.20260106135339-3745d9b14a30 // indirect github.com/olekukonko/tablewriter v0.0.6-0.20230925090304-df64c4bbad77 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect @@ -143,7 +142,6 @@ require ( golang.org/x/crypto v0.47.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.41.0 // indirect diff --git a/go.sum b/go.sum index 3ffbeb7b..b3ca7c12 100644 --- a/go.sum +++ b/go.sum @@ -161,8 +161,6 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TC github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= -github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -217,8 +215,6 @@ github.com/gptscript-ai/chat-completion-client v0.0.0-20250224164718-139cb4507b1 github.com/gptscript-ai/chat-completion-client v0.0.0-20250224164718-139cb4507b1d/go.mod h1:7P/o6/IWa1KqsntVf68hSnLKuu3+xuqm6lYhch1w4jo= github.com/gptscript-ai/cmd v0.0.0-20250530150401-bc71fddf8070 h1:xm5ZZFraWFwxyE7TBEncCXArubCDZTwG6s5bpMzqhSY= github.com/gptscript-ai/cmd v0.0.0-20250530150401-bc71fddf8070/go.mod h1:DJAo1xTht1LDkNYFNydVjTHd576TC7MlpsVRl3oloVw= -github.com/gptscript-ai/go-gptscript v0.9.6-0.20250714170123-17ad44ae8c54 h1:9OAiDBdOQUHVL89wmb38+/XOuewboMhgnk6NqoJiC00= -github.com/gptscript-ai/go-gptscript v0.9.6-0.20250714170123-17ad44ae8c54/go.mod h1:HLPvKBhDtsEkyyUWefJVhPpl98R3tZG6ps7+mQ+EKVI= github.com/gptscript-ai/tui v0.0.0-20250419050840-5e79e16786c9 h1:wQC8sKyeGA50WnCEG+Jo5FNRIkuX3HX8d3ubyWCCoI8= github.com/gptscript-ai/tui v0.0.0-20250419050840-5e79e16786c9/go.mod h1:iwHxuueg2paOak7zIg0ESBWx7A0wIHGopAratbgaPNY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -305,16 +301,12 @@ github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZz github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= -github.com/modelcontextprotocol/go-sdk v0.2.0 h1:PESNYOmyM1c369tRkzXLY5hHrazj8x9CY1Xu0fLCryM= -github.com/modelcontextprotocol/go-sdk v0.2.0/go.mod h1:0sL9zUKKs2FTTkeCCVnKqbLJTw5TScefPAzojjU459E= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/nanobot-ai/nanobot v0.0.52 h1:Jf3VtKljPcJBS2tZIxzcx3u5mJmrgn9HQ54/NQQ9JOo= -github.com/nanobot-ai/nanobot v0.0.52/go.mod h1:Es0FimWKLR9KtK2qi1MU7GfAD/g6PLQjbkAv/3PnzBc= github.com/nightlyone/lockfile v1.0.0 h1:RHep2cFKK4PonZJDdEl4GmkabuhbsRMgk/k3uAmxBiA= github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatROs6LzC841CI= github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= @@ -323,8 +315,6 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= -github.com/obot-platform/mcp-oauth-proxy v0.0.3-0.20260106135339-3745d9b14a30 h1:PbvvLXjoUHQGpQ4ZzV9Aj8n8y0evpZljPlpDu96lFC4= -github.com/obot-platform/mcp-oauth-proxy v0.0.3-0.20260106135339-3745d9b14a30/go.mod h1:8I+MeGRPsv42hk7/MCSqWHvz4p7xvIR0CIh1GG3vtXM= github.com/olekukonko/tablewriter v0.0.6-0.20230925090304-df64c4bbad77 h1:3bMMZ1f+GPXFQ1uNaYbO/uECWvSfqEA+ZEXn1rFAT88= github.com/olekukonko/tablewriter v0.0.6-0.20230925090304-df64c4bbad77/go.mod h1:8Hf+pH6thup1sPZPD+NLg7d6vbpsdilu9CPIeikvgMQ= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= diff --git a/pkg/cli/main.go b/pkg/cli/main.go index 33048e0e..a21ab5ae 100644 --- a/pkg/cli/main.go +++ b/pkg/cli/main.go @@ -9,7 +9,7 @@ import ( "github.com/gptscript-ai/cmd" "github.com/gptscript-ai/gptscript/pkg/daemon" "github.com/gptscript-ai/gptscript/pkg/mvl" - "github.com/nanobot-ai/nanobot/pkg/supervise" + "github.com/gptscript-ai/gptscript/pkg/nanobot/supervise" ) func Main() { diff --git a/pkg/loader/openapi.go b/pkg/loader/openapi.go index 7c08a54d..5277cf30 100644 --- a/pkg/loader/openapi.go +++ b/pkg/loader/openapi.go @@ -12,9 +12,9 @@ import ( "time" "github.com/getkin/kin-openapi/openapi3" + "github.com/google/jsonschema-go/jsonschema" "github.com/gptscript-ai/gptscript/pkg/openapi" "github.com/gptscript-ai/gptscript/pkg/types" - "github.com/google/jsonschema-go/jsonschema" ) var toolNameRegex = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) diff --git a/pkg/mcp/client.go b/pkg/mcp/client.go index a6563bd2..92d2aca6 100644 --- a/pkg/mcp/client.go +++ b/pkg/mcp/client.go @@ -1,7 +1,7 @@ package mcp import ( - nmcp "github.com/nanobot-ai/nanobot/pkg/mcp" + nmcp "github.com/gptscript-ai/gptscript/pkg/nanobot/mcp" ) func (l *Local) Client(server ServerConfig, clientOpts ...nmcp.ClientOption) (*Client, error) { diff --git a/pkg/mcp/loader.go b/pkg/mcp/loader.go index efef3c2b..16e0cc0c 100644 --- a/pkg/mcp/loader.go +++ b/pkg/mcp/loader.go @@ -10,11 +10,11 @@ import ( "strings" "sync" + "github.com/google/jsonschema-go/jsonschema" "github.com/gptscript-ai/gptscript/pkg/hash" "github.com/gptscript-ai/gptscript/pkg/mvl" + nmcp "github.com/gptscript-ai/gptscript/pkg/nanobot/mcp" "github.com/gptscript-ai/gptscript/pkg/types" - "github.com/google/jsonschema-go/jsonschema" - nmcp "github.com/nanobot-ai/nanobot/pkg/mcp" ) var ( diff --git a/pkg/nanobot/complete/complete.go b/pkg/nanobot/complete/complete.go new file mode 100644 index 00000000..cd9d1257 --- /dev/null +++ b/pkg/nanobot/complete/complete.go @@ -0,0 +1,56 @@ +package complete + +type Merger[T any] interface { + Merge(T) T +} + +type Completer[T any] interface { + Complete() T +} + +func MergeMap[K comparable, V any](m ...map[K]V) (result map[K]V) { + if len(m) == 0 { + return nil + } + result = make(map[K]V) + for _, mm := range m { + for k, v := range mm { + result[k] = v + } + } + return +} + +func First[T comparable](o ...T) (result T) { + for _, v := range o { + if v != result { + return v + } + } + return +} + +func Last[T comparable](o ...T) (result T) { + for i := len(o) - 1; i >= 0; i-- { + if o[i] != result { + return o[i] + } + } + return +} + +func Merge[T Merger[T]](opts ...T) T { + var all T + for _, opt := range opts { + all = all.Merge(opt) + } + return all +} + +func Complete[T Merger[T]](opts ...T) T { + merged := Merge(opts...) + if c, ok := any(merged).(Completer[T]); ok { + return c.Complete() + } + return merged +} diff --git a/pkg/nanobot/envvar/replace.go b/pkg/nanobot/envvar/replace.go new file mode 100644 index 00000000..009ed226 --- /dev/null +++ b/pkg/nanobot/envvar/replace.go @@ -0,0 +1,44 @@ +package envvar + +import ( + "context" + "fmt" + "maps" + "slices" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/expr" + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" +) + +func ReplaceString(envs map[string]string, str string) string { + r, err := expr.EvalString(context.TODO(), envs, nil, str) + if err != nil { + log.Errorf(context.TODO(), "failed to evaluate expression %s: %v", str, err) + return str + } + return r +} + +func ReplaceMap(envs map[string]string, m map[string]string) map[string]string { + newMap := make(map[string]string, len(m)) + for k, v := range m { + newMap[ReplaceString(envs, k)] = ReplaceString(envs, v) + } + return newMap +} + +func ReplaceEnv(envs map[string]string, command string, args []string, env map[string]string) (string, []string, []string) { + newEnvMap := make(map[string]string, len(env)) + maps.Copy(newEnvMap, ReplaceMap(envs, env)) + + newEnv := make([]string, 0, len(env)) + for _, k := range slices.Sorted(maps.Keys(newEnvMap)) { + newEnv = append(newEnv, fmt.Sprintf("%s=%s", k, newEnvMap[k])) + } + + newArgs := make([]string, len(args)) + for i, arg := range args { + newArgs[i] = ReplaceString(envs, arg) + } + return ReplaceString(envs, command), newArgs, newEnv +} diff --git a/pkg/nanobot/expr/eval.go b/pkg/nanobot/expr/eval.go new file mode 100644 index 00000000..67621c20 --- /dev/null +++ b/pkg/nanobot/expr/eval.go @@ -0,0 +1,169 @@ +package expr + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/dop251/goja" +) + +func EvalString(ctx context.Context, env map[string]string, data map[string]any, expr string) (string, error) { + ret, err := evalString(ctx, env, data, expr) + if err != nil { + return "", fmt.Errorf("failed to evaluate string expression %s: %w", expr, err) + } + if ret == nil { + return "", nil + } + if str, ok := ret.(string); ok { + return str, nil + } + return "", fmt.Errorf("expected a string expression, got %T when evaluating %s", ret, expr) +} + +func EvalBool(ctx context.Context, env map[string]string, data map[string]any, expr any) (bool, error) { + ret, err := translate(ctx, env, data, expr) + if err != nil { + return false, err + } + if ret == nil { + return false, nil + } + switch v := ret.(type) { + case string: + return strings.Contains(strings.ToLower(v), "t"), nil + case bool: + return v, nil + default: + return false, fmt.Errorf("expected a boolean expression, got %T when evaluating %v", ret, expr) + } +} + +func EvalAny(ctx context.Context, env map[string]string, data map[string]any, expr any) (any, error) { + return translate(ctx, env, data, expr) +} + +func EvalObject(ctx context.Context, env map[string]string, data map[string]any, expr any) (any, error) { + ret, err := translate(ctx, env, data, expr) + if err != nil { + return nil, err + } + if ret == nil { + return map[string]any{}, nil + } + return ret, nil +} + +func translate(ctx context.Context, env map[string]string, data map[string]any, expr any) (any, error) { + switch expr := (expr).(type) { + case nil: + return nil, nil + case []any: + result := make([]any, len(expr)) + for i, item := range expr { + res, err := translate(ctx, env, data, item) + if err != nil { + return nil, err + } + result[i] = res + } + return result, nil + case map[string]any: + result := make(map[string]any) + for key, value := range expr { + res, err := translate(ctx, env, data, value) + if err != nil { + return nil, err + } + result[key] = res + } + case string: + return evalString(ctx, env, data, expr) + } + return expr, nil +} + +func newRuntime(data map[string]any) (*goja.Runtime, error) { + runtime := goja.New() + for key, value := range data { + if err := runtime.Set(key, value); err != nil { + return nil, fmt.Errorf("failed to set variable %s: %w", key, err) + } + } + return runtime, nil +} + +func evalString(_ context.Context, env map[string]string, data map[string]any, expr string) (any, error) { + if strings.TrimSpace(expr) == "" { + return "", nil + } + + if strings.HasPrefix(expr, "${") && strings.HasSuffix(expr, "}") { + envVal, ok := Lookup(env, expr[2:len(expr)-1]) + if ok { + return envVal, nil + } + runtime, err := newRuntime(data) + if err != nil { + return nil, err + } + val, err := runtime.RunString(expr[2 : len(expr)-1]) + if err != nil { + return nil, fmt.Errorf("failed to evaluate expression %s: %w", expr, err) + } + if val.String() == "undefined" { + return nil, fmt.Errorf("expression resulted in a javascript undefined, check for a missing reference in expression %q", expr) + } + return val.Export(), nil + } + + runtime, err := newRuntime(data) + if err != nil { + return nil, err + } + + var lastErr error + return Expand(expr, func(name string) string { + if lastErr != nil { + return name + } + envVal, ok := Lookup(env, name) + if ok { + return envVal + } + val, err := runtime.RunString(name) + if err != nil { + lastErr = err + return "" + } + nativeVal := val.Export() + switch nativeVal := nativeVal.(type) { + case string: + return nativeVal + default: + result, err := json.Marshal(nativeVal) + if err != nil { + lastErr = err + return "" + } + return string(result) + } + }), lastErr +} + +func EvalList(ctx context.Context, env map[string]string, data map[string]any, expr any) ([]any, error) { + val, err := translate(ctx, env, data, expr) + if err != nil { + return nil, fmt.Errorf("failed to evaluate list: %w", err) + } + switch val := val.(type) { + case []any: + return val, nil + case nil: + return nil, nil + default: + return nil, fmt.Errorf("expected a list, got %T", val) + } +} diff --git a/pkg/nanobot/expr/expand.go b/pkg/nanobot/expr/expand.go new file mode 100644 index 00000000..a777e52d --- /dev/null +++ b/pkg/nanobot/expr/expand.go @@ -0,0 +1,69 @@ +package expr + +import ( + "strings" +) + +// Expand is similar to os.Expand but handles nested curly braces correctly. +// It replaces ${var} in the input string with the value returned by the mapping function. +// If var contains curly braces, the entire var name is passed to the mapping function. +func Expand(s string, mapping func(string) string) string { + // First, check if the string has any unclosed variables + return expandString(s, mapping, 0) +} + +// expandString is a helper function that handles the actual expansion. +// The depth parameter is used to prevent infinite recursion. +func expandString(s string, mapping func(string) string, depth int) string { + // Prevent infinite recursion + if depth > 100 { + return s + } + + var buf strings.Builder + // i is the index in s of the next character to be processed + i := 0 + for j := 0; j < len(s); j++ { + if s[j] == '$' && j+1 < len(s) { + if s[j+1] == '{' { + buf.WriteString(s[i:j]) + // Find the matching closing brace, accounting for nested braces + start := j + 2 // Skip the "${" prefix + braceCount := 1 + end := start + for end < len(s) && braceCount > 0 { + if s[end] == '{' { + braceCount++ + } else if s[end] == '}' { + braceCount-- + } + end++ + } + + if braceCount == 0 { + // We found the matching closing brace + name := s[start : end-1] + // First, recursively expand any variables in the name + expandedName := expandString(name, mapping, depth+1) + buf.WriteString(mapping(expandedName)) + j = end - 1 // -1 because the loop will increment j + i = end + } else { + // This should never happen since we check for unclosed variables first + return s + } + } else { + // Not a variable reference, just a literal '$' + buf.WriteString(s[i : j+1]) + i = j + 1 + } + } + } + + // Append any remaining characters + if i < len(s) { + buf.WriteString(s[i:]) + } + + return buf.String() +} diff --git a/pkg/nanobot/expr/expand_test.go b/pkg/nanobot/expr/expand_test.go new file mode 100644 index 00000000..2ff9b236 --- /dev/null +++ b/pkg/nanobot/expr/expand_test.go @@ -0,0 +1,93 @@ +package expr + +import ( + "testing" +) + +func TestCustomExpand(t *testing.T) { + tests := []struct { + name string + input string + mapping map[string]string + expected string + }{ + { + name: "simple variable", + input: "Hello, ${name}!", + mapping: map[string]string{"name": "World"}, + expected: "Hello, World!", + }, + { + name: "variable with curly braces", + input: "Value: ${foo{x}}", + mapping: map[string]string{"foo{x}": "bar"}, + expected: "Value: bar", + }, + { + name: "nested variables", + input: "Nested: ${outer{${inner}}}", + mapping: map[string]string{"inner": "value", "outer{value}": "result"}, + expected: "Nested: result", + }, + { + name: "multiple variables", + input: "${a} ${b{c}} ${d}", + mapping: map[string]string{"a": "first", "b{c}": "second", "d": "third"}, + expected: "first second third", + }, + { + name: "no variables", + input: "Plain text", + mapping: map[string]string{}, + expected: "Plain text", + }, + { + name: "unclosed variable", + input: "Unclosed ${variable", + mapping: map[string]string{"variable": "value"}, + expected: "Unclosed ${variable", + }, + { + name: "escaped dollar", + input: "Cost: $5.00", + mapping: map[string]string{}, + expected: "Cost: $5.00", + }, + { + name: "recursive variable expansion", + input: "${foo${bar}}", + mapping: map[string]string{"foobaz": "correct", "bar": "baz"}, + expected: "correct", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mapping := func(key string) string { + return tt.mapping[key] + } + result := Expand(tt.input, mapping) + if result != tt.expected { + t.Errorf("Expand(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +// TestCustomExpandVsOsExpand tests that Expand handles nested braces correctly +// while os.Expand would not. +func TestCustomExpandNestedBraces(t *testing.T) { + input := "Value: ${foo{x}}" + mapping := map[string]string{"foo{x}": "correct", "x": "wrong"} + + mapFunc := func(key string) string { + return mapping[key] + } + + result := Expand(input, mapFunc) + expected := "Value: correct" + + if result != expected { + t.Errorf("Expand(%q) = %q, want %q", input, result, expected) + } +} diff --git a/pkg/nanobot/expr/lookup.go b/pkg/nanobot/expr/lookup.go new file mode 100644 index 00000000..50e4b555 --- /dev/null +++ b/pkg/nanobot/expr/lookup.go @@ -0,0 +1,50 @@ +package expr + +import ( + "strings" + "time" +) + +func builtinEnv(envMap map[string]string, key string) (string, bool) { + switch key { + case "nanobot:time": + now := time.Now() + if tz, ok := envMap["TZ"]; ok { + loc, err := time.LoadLocation(tz) + if err != nil { + return "", false + } + now = now.In(loc) + } + if format, ok := envMap["TIME_FORMAT"]; ok { + return now.Format(format), true + } + return now.Format(time.RFC3339), true + default: + return "", false + } +} + +func Lookup(envMap map[string]string, envKey string) (string, bool) { + v, ok := builtinEnv(envMap, envKey) + if ok { + return v, true + } + + val, ok := envMap[envKey] + if ok { + return val, true + } + for envMapKey, envMapVal := range envMap { + if strings.EqualFold(envKey, strings.ReplaceAll(envMapKey, "-", "_")) { + val = envMapVal + ok = true + break + } + } + if ok { + return val, true + } + + return "", false +} diff --git a/pkg/nanobot/log/log.go b/pkg/nanobot/log/log.go new file mode 100644 index 00000000..417dd2ba --- /dev/null +++ b/pkg/nanobot/log/log.go @@ -0,0 +1,62 @@ +package log + +import ( + "bytes" + "context" + "fmt" + "os" + "regexp" + "slices" + "strings" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/printer" +) + +var ( + debugs = strings.Split(os.Getenv("NANOBOT_DEBUG"), ",") + EnableMessages = slices.Contains(debugs, "messages") + EnableProgress = slices.Contains(debugs, "progress") + DebugLog = slices.Contains(debugs, "log") + Base64Replace = regexp.MustCompile(`((;base64,|")[a-zA-Z0-9+/=]{60})[a-zA-Z0-9+/=]+"`) + Base64Replacement = []byte(`$1..."`) +) + +func Messages(_ context.Context, server string, out bool, data []byte) { + if EnableProgress && bytes.Contains(data, []byte(`"notifications/progress"`)) { + } else if EnableMessages && !bytes.Contains(data, []byte(`"notifications/progress"`)) { + } else if slices.Contains(debugs, server) { + } else { + return + } + + prefixFmt := "->(%s)" + if !out { + prefixFmt = "<-(%s)" + } + data = Base64Replace.ReplaceAll(data, Base64Replacement) + printer.Prefix(fmt.Sprintf(prefixFmt, server), strings.ReplaceAll(strings.TrimSpace(string(data)), "\n", " ")+"\n") +} + +func StderrMessages(_ context.Context, server, line string) { + printer.Prefix(fmt.Sprintf("<-(%s:stderr)", server), line+"\n") +} + +func Errorf(_ context.Context, format string, args ...any) { + printer.Prefix("error", fmt.Sprintf(format+"\n", args...)) +} + +func Infof(_ context.Context, format string, args ...any) { + printer.Prefix("info", fmt.Sprintf(format+"\n", args...)) +} + +func Fatalf(_ context.Context, format string, args ...any) { + printer.Prefix("fatal", fmt.Sprintf(format+"\n", args...)) + os.Exit(1) +} + +func Debugf(_ context.Context, format string, args ...any) { + if !DebugLog { + return + } + printer.Prefix("debug", fmt.Sprintf(format+"\n", args...)) +} diff --git a/pkg/nanobot/mcp/callback.go b/pkg/nanobot/mcp/callback.go new file mode 100644 index 00000000..05820f6c --- /dev/null +++ b/pkg/nanobot/mcp/callback.go @@ -0,0 +1,85 @@ +package mcp + +import ( + "context" + "crypto/rand" + "net/http" + "strings" + "sync" + + "golang.org/x/oauth2" +) + +type AuthURLHandler interface { + HandleAuthURL(context.Context, string, string) (bool, error) +} + +type CallbackHandler interface { + AuthURLHandler + NewState(context.Context, *oauth2.Config, string) (string, <-chan CallbackPayload, error) +} + +type CallbackServer interface { + http.Handler + CallbackHandler +} + +type CallbackPayload struct { + Code string `json:"code"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +type callbackHandler struct { + AuthURLHandler + lock *sync.Mutex + state map[string]callback +} + +func NewCallbackServer(authURLHandler AuthURLHandler) CallbackServer { + return &callbackHandler{ + lock: new(sync.Mutex), + state: make(map[string]callback), + AuthURLHandler: authURLHandler, + } +} + +func (s *callbackHandler) NewState(_ context.Context, conf *oauth2.Config, _ string) (string, <-chan CallbackPayload, error) { + state := strings.ToLower(rand.Text()) + ch := make(chan CallbackPayload) + s.lock.Lock() + s.state[state] = callback{ + conf: conf, + ch: ch, + } + s.lock.Unlock() + return state, ch, nil +} + +func (s *callbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + + s.lock.Lock() + c, ok := s.state[state] + delete(s.state, state) + s.lock.Unlock() + + if !ok { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + + c.ch <- CallbackPayload{ + Code: r.URL.Query().Get("code"), + Error: r.URL.Query().Get("error"), + ErrorDescription: r.URL.Query().Get("error_description"), + } + close(c.ch) + + _, _ = w.Write([]byte("Success!!")) +} + +type callback struct { + conf *oauth2.Config + ch chan<- CallbackPayload +} diff --git a/pkg/nanobot/mcp/client.go b/pkg/nanobot/mcp/client.go new file mode 100644 index 00000000..93eba43c --- /dev/null +++ b/pkg/nanobot/mcp/client.go @@ -0,0 +1,460 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/complete" + "github.com/gptscript-ai/gptscript/pkg/nanobot/envvar" + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" + "github.com/gptscript-ai/gptscript/pkg/nanobot/version" +) + +type Client struct { + Session *Session +} + +func (c *Client) Close(deleteSession bool) { + c.Session.Close(deleteSession) +} + +type SessionState struct { + ID string `json:"id,omitempty"` + InitializeResult InitializeResult `json:"initializeResult,omitempty"` + InitializeRequest InitializeRequest `json:"initializeRequest,omitempty"` + Attributes map[string]any `json:"attributes,omitempty"` +} + +type ClientOption struct { + Roots func(ctx context.Context) ([]Root, error) + OnSampling func(ctx context.Context, sampling CreateMessageRequest) (CreateMessageResult, error) + OnElicit func(ctx context.Context, req ElicitRequest) (ElicitResult, error) + OnRoots func(ctx context.Context, msg Message) error + OnLogging func(ctx context.Context, logMsg LoggingMessage) error + OnMessage func(ctx context.Context, msg Message) error + OnNotify func(ctx context.Context, msg Message) error + Env map[string]string + ParentSession *Session + SessionState *SessionState + Runner *Runner + ClientName string + ClientVersion string + OAuthClientName string + OAuthRedirectURL string + CallbackHandler CallbackHandler + ClientCredLookup ClientCredLookup + TokenStorage TokenStorage + Wire Wire +} + +func (c ClientOption) Complete() ClientOption { + if c.Runner == nil { + c.Runner = &Runner{} + } + if c.ClientCredLookup == nil { + c.ClientCredLookup = NewClientLookupFromEnv() + } + if c.TokenStorage == nil { + c.TokenStorage = NewDefaultLocalStorage() + } + if c.OAuthClientName == "" { + c.OAuthClientName = "Nanobot MCP Client" + } + if c.ClientName == "" { + c.ClientName = "nanobot" + c.ClientVersion = version.Get().String() + } else { + c.ClientName += fmt.Sprintf(" (via nanobot %s)", version.Get().String()) + } + return c +} + +func (c ClientOption) Merge(other ClientOption) (result ClientOption) { + result.OnSampling = c.OnSampling + if other.OnSampling != nil { + result.OnSampling = other.OnSampling + } + result.OnRoots = c.OnRoots + if other.OnRoots != nil { + result.OnRoots = other.OnRoots + } + result.OnLogging = c.OnLogging + if other.OnLogging != nil { + result.OnLogging = other.OnLogging + } + result.OnMessage = c.OnMessage + if other.OnMessage != nil { + result.OnMessage = other.OnMessage + } + result.OnNotify = c.OnNotify + if other.OnNotify != nil { + result.OnNotify = other.OnNotify + } + result.OnElicit = c.OnElicit + if other.OnElicit != nil { + result.OnElicit = other.OnElicit + } + result.CallbackHandler = c.CallbackHandler + if other.CallbackHandler != nil { + result.CallbackHandler = other.CallbackHandler + } + result.ClientCredLookup = c.ClientCredLookup + if other.ClientCredLookup != nil { + result.ClientCredLookup = other.ClientCredLookup + } + result.TokenStorage = c.TokenStorage + if other.TokenStorage != nil { + result.TokenStorage = other.TokenStorage + } + result.ClientName = complete.Last(c.ClientName, other.ClientName) + result.ClientVersion = complete.Last(c.ClientVersion, other.ClientVersion) + result.OAuthRedirectURL = complete.Last(c.OAuthRedirectURL, other.OAuthRedirectURL) + result.OAuthClientName = complete.Last(c.OAuthClientName, other.OAuthClientName) + result.Env = complete.MergeMap(c.Env, other.Env) + result.SessionState = complete.Last(c.SessionState, other.SessionState) + result.ParentSession = complete.Last(c.ParentSession, other.ParentSession) + result.Runner = complete.Last(c.Runner, other.Runner) + result.Wire = complete.Last(c.Wire, other.Wire) + + result.Roots = c.Roots + if other.Roots != nil { + result.Roots = other.Roots + } + + return result +} + +type Server struct { + Name string `json:"name,omitempty"` + ShortName string `json:"shortName,omitempty"` + Description string `json:"description,omitempty"` + + Image string `json:"image,omitempty"` + Dockerfile string `json:"dockerfile,omitempty"` + Source ServerSource `json:"source,omitempty"` + Sandboxed bool `json:"sandboxed,omitempty"` + Env map[string]string `json:"env,omitempty"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + BaseURL string `json:"url,omitempty"` + Ports []string `json:"ports,omitempty"` + ReversePorts []int `json:"reversePorts"` + Cwd string `json:"cwd,omitempty"` + Workdir string `json:"workdir,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +type ServerSource struct { + Repo string `json:"repo,omitempty"` + Tag string `json:"tag,omitempty"` + Commit string `json:"commit,omitempty"` + Branch string `json:"branch,omitempty"` + SubPath string `json:"subPath,omitempty"` + Reference string `json:"reference,omitempty"` +} + +func (s *ServerSource) UnmarshalJSON(data []byte) error { + if len(data) > 0 && data[0] == '"' { + // If the data is a string, treat it as a repo URL + var subPath string + if err := json.Unmarshal(data, &subPath); err != nil { + return fmt.Errorf("failed to unmarshal server source: %w", err) + } + s.SubPath = subPath + return nil + } + type Alias ServerSource + return json.Unmarshal(data, (*Alias)(s)) +} + +func toHandler(opts ClientOption) MessageHandler { + return MessageHandlerFunc(func(ctx context.Context, msg Message) { + if msg.Method == "sampling/createMessage" && opts.OnSampling != nil { + var param CreateMessageRequest + if err := json.Unmarshal(msg.Params, ¶m); err != nil { + msg.SendError(ctx, fmt.Errorf("failed to unmarshal sampling/createMessage: %w", err)) + return + } + go func() { + resp, err := opts.OnSampling(ctx, param) + if err != nil { + if errors.Is(err, ErrNoReader) { + msg.SendError(ctx, ErrRPCMethodNotFound) + } else { + msg.SendError(ctx, fmt.Errorf("failed to handle sampling/createMessage: %w", err)) + } + return + } + err = msg.Reply(ctx, resp) + if err != nil { + log.Errorf(ctx, "failed to reply to sampling/createMessage: %v", err) + } + }() + } else if msg.Method == "elicitation/create" && opts.OnElicit != nil { + var param ElicitRequest + if err := json.Unmarshal(msg.Params, ¶m); err != nil { + msg.SendError(ctx, fmt.Errorf("failed to unmarshal elicitation/create: %w", err)) + return + } + go func() { + resp, err := opts.OnElicit(ctx, param) + if err != nil { + if errors.Is(err, ErrNoReader) { + msg.SendError(ctx, ErrRPCMethodNotFound) + } else { + msg.SendError(ctx, fmt.Errorf("failed to handle elicitation/create: %w", err)) + } + return + } + err = msg.Reply(ctx, resp) + if err != nil { + log.Errorf(ctx, "failed to reply to elicitation/create: %v", err) + } + }() + } else if msg.Method == "roots/list" && opts.OnRoots != nil { + go func() { + if err := opts.OnRoots(ctx, msg); err != nil && !errors.Is(err, ErrNoReader) { + msg.SendError(ctx, fmt.Errorf("failed to handle roots/list: %w", err)) + } + }() + } else if msg.Method == "notifications/message" && opts.OnLogging != nil { + var param LoggingMessage + if err := json.Unmarshal(msg.Params, ¶m); err != nil { + msg.SendError(ctx, fmt.Errorf("failed to unmarshal notifications/message: %w", err)) + return + } + if err := opts.OnLogging(ctx, param); err != nil && !errors.Is(err, ErrNoReader) { + msg.SendError(ctx, fmt.Errorf("failed to handle notifications/message: %w", err)) + } + } else if strings.HasPrefix(msg.Method, "notifications/") && opts.OnNotify != nil { + if err := opts.OnNotify(ctx, msg); err != nil && !errors.Is(err, ErrNoReader) { + log.Errorf(ctx, "failed to handle notification: %v", err) + } + } else if opts.OnMessage != nil { + if err := opts.OnMessage(ctx, msg); err != nil && !errors.Is(err, ErrNoReader) { + log.Errorf(ctx, "failed to handle message: %v", err) + } + } + }) +} + +func waitForURL(ctx context.Context, serverName, baseURL string) error { + if baseURL == "" { + return fmt.Errorf("base URL is empty for server %s", serverName) + } + + for i := 0; i < 120; i++ { + if i%20 == 0 { + log.Infof(ctx, "Waiting for server %s at %s to be ready...", serverName, baseURL) + } + resp, err := http.Get(baseURL) + if err != nil { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled while waiting for server %s at %s: %w", serverName, baseURL, ctx.Err()) + case <-time.After(500 * time.Millisecond): + } + } else { + _ = resp.Body.Close() + log.Infof(ctx, "Server %s at %s is ready", serverName, baseURL) + return nil + } + } + + return fmt.Errorf("server %s at %s did not respond within the timeout period", serverName, baseURL) +} + +func NewSession(ctx context.Context, serverName string, config Server, opts ...ClientOption) (*Session, error) { + var ( + wire Wire + err error + opt = complete.Complete(opts...) + ) + + if opt.Wire != nil { + wire = opt.Wire + } else if config.Command == "" && config.BaseURL == "" { + return nil, fmt.Errorf("no command or base URL provided") + } else if config.BaseURL != "" { + if (opt.CallbackHandler != nil) != (opt.OAuthRedirectURL != "") { + return nil, fmt.Errorf("must specify both or neither callback server and OAuth redirect URL") + } + + if config.Command != "" { + var err error + config, err = opt.Runner.Run(ctx, opt.Roots, opt.Env, serverName, config) + if err != nil { + return nil, err + } + if err := waitForURL(ctx, serverName, config.BaseURL); err != nil { + return nil, err + } + } + headers := envvar.ReplaceMap(opt.Env, config.Headers) + if opt.SessionState != nil && opt.SessionState.ID != "" { + if headers == nil { + headers = make(map[string]string) + } + headers["Mcp-Session-Id"] = opt.SessionState.ID + } + wire = newHTTPClient(serverName, config.BaseURL, opt.OAuthClientName, opt.OAuthRedirectURL, opt.CallbackHandler, opt.ClientCredLookup, opt.TokenStorage, headers) + } else { + wire, err = newStdioClient(ctx, opt.Roots, opt.Env, serverName, config, opt.Runner) + if err != nil { + return nil, err + } + } + + session, err := newSession(ctx, wire, toHandler(opt), opt.SessionState, nil) + if err != nil { + return nil, err + } + session.Parent = opt.ParentSession + return session, nil +} + +func NewClient(ctx context.Context, serverName string, config Server, opts ...ClientOption) (*Client, error) { + var ( + opt = complete.Complete(opts...) + session *Session + err error + ) + + session, err = NewSession(ctx, serverName, config, opts...) + if err != nil { + return nil, err + } + + c := &Client{ + Session: session, + } + + var ( + sampling *struct{} + roots *RootsCapability + elicitations *struct{} + ) + if opt.OnSampling != nil { + sampling = &struct{}{} + } + if opt.OnRoots != nil { + roots = &RootsCapability{} + } + if opt.OnElicit != nil { + elicitations = &struct{}{} + } + if opt.SessionState == nil { + _, err = c.Initialize(ctx, InitializeRequest{ + ProtocolVersion: "2025-06-18", + Capabilities: ClientCapabilities{ + Sampling: sampling, + Roots: roots, + Elicitation: elicitations, + }, + ClientInfo: ClientInfo{ + Name: opt.ClientName, + Version: opt.ClientVersion, + }, + }) + return c, err + } + + return c, nil +} + +func (c *Client) Initialize(ctx context.Context, param InitializeRequest) (result InitializeResult, err error) { + err = c.Session.Exchange(ctx, "initialize", param, &result) + if err == nil { + err = c.Session.Send(ctx, Message{ + Method: "notifications/initialized", + }) + } + return +} + +func (c *Client) ReadResource(ctx context.Context, uri string) (*ReadResourceResult, error) { + var result ReadResourceResult + err := c.Session.Exchange(ctx, "resources/read", ReadResourceRequest{ + URI: uri, + }, &result) + return &result, err +} + +func (c *Client) ListResourceTemplates(ctx context.Context) (*ListResourceTemplatesResult, error) { + var result ListResourceTemplatesResult + if c.Session.InitializeResult.Capabilities.Resources == nil { + return &result, nil + } + err := c.Session.Exchange(ctx, "resources/templates/list", struct{}{}, &result) + return &result, err +} + +func (c *Client) ListResources(ctx context.Context) (*ListResourcesResult, error) { + var result ListResourcesResult + if c.Session.InitializeResult.Capabilities.Resources == nil { + return &result, nil + } + err := c.Session.Exchange(ctx, "resources/list", struct{}{}, &result) + return &result, err +} + +func (c *Client) ListPrompts(ctx context.Context) (*ListPromptsResult, error) { + var prompts ListPromptsResult + if c.Session.InitializeResult.Capabilities.Prompts == nil { + return &prompts, nil + } + err := c.Session.Exchange(ctx, "prompts/list", struct{}{}, &prompts) + return &prompts, err +} + +func (c *Client) GetPrompt(ctx context.Context, name string, args map[string]string) (*GetPromptResult, error) { + var result GetPromptResult + err := c.Session.Exchange(ctx, "prompts/get", GetPromptRequest{ + Name: name, + Arguments: args, + }, &result) + return &result, err +} + +func (c *Client) ListTools(ctx context.Context) (*ListToolsResult, error) { + var tools ListToolsResult + err := c.Session.Exchange(ctx, "tools/list", struct{}{}, &tools) + return &tools, err +} + +func (c *Client) Ping(ctx context.Context) (*PingResult, error) { + var result PingResult + err := c.Session.Exchange(ctx, "ping", PingRequest{}, &result) + return &result, err +} + +type CallOption struct { + ProgressToken any +} + +func (c CallOption) Merge(other CallOption) (result CallOption) { + result.ProgressToken = complete.Last(c.ProgressToken, other.ProgressToken) + return +} + +func (c *Client) Call(ctx context.Context, tool string, args any, opts ...CallOption) (result *CallToolResult, err error) { + opt := complete.Complete(opts...) + result = new(CallToolResult) + + err = c.Session.Exchange(ctx, "tools/call", struct { + Name string `json:"name"` + Arguments any `json:"arguments,omitempty"` + }{ + Name: tool, + Arguments: args, + }, result, ExchangeOption{ + ProgressToken: opt.ProgressToken, + }) + + return +} diff --git a/pkg/nanobot/mcp/clientlookup.go b/pkg/nanobot/mcp/clientlookup.go new file mode 100644 index 00000000..ff25929a --- /dev/null +++ b/pkg/nanobot/mcp/clientlookup.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" +) + +type ClientCredLookup interface { + Lookup(context.Context, string) (string, string, error) +} + +func NewClientLookupFromEnv() ClientCredLookup { + return &envClientCredLookup{} +} + +type envClientCredLookup struct{} + +func (l *envClientCredLookup) Lookup(_ context.Context, authURL string) (string, string, error) { + clientIDEnvVar, clientSecretEnvVar, err := AuthURLToEnvVars(authURL) + if err != nil { + return "", "", err + } + + return os.Getenv(clientIDEnvVar), os.Getenv(clientSecretEnvVar), nil +} + +func AuthURLToEnvVars(authURL string) (string, string, error) { + u, err := url.Parse(authURL) + if err != nil { + return "", "", fmt.Errorf("failed to parse url: %w", err) + } + + envBase := strings.ReplaceAll(strings.ReplaceAll(u.Host, ".", "_"), ":", "_") + if u.Path != "" && u.Path != "/" { + envBase += strings.ReplaceAll(strings.TrimSuffix(u.Path, "/"), "/", "_") + } + + envBase = strings.ToUpper(strings.ReplaceAll(envBase, "-", "_")) + return envBase + "_CLIENT_ID", envBase + "_CLIENT_SECRET", nil +} diff --git a/pkg/nanobot/mcp/error.go b/pkg/nanobot/mcp/error.go new file mode 100644 index 00000000..1b4b4d59 --- /dev/null +++ b/pkg/nanobot/mcp/error.go @@ -0,0 +1,21 @@ +package mcp + +import "fmt" + +type AuthRequiredErr struct { + ProtectedResourceValue string + Err error +} + +func (e AuthRequiredErr) Error() string { + return fmt.Sprintf("authentication required: %v", e.Err) +} + +type SessionNotFoundErr struct { + SessionID string + Err error +} + +func (e SessionNotFoundErr) Error() string { + return fmt.Sprintf("session %s not found: %v", e.SessionID, e.Err) +} diff --git a/pkg/nanobot/mcp/httpclient.go b/pkg/nanobot/mcp/httpclient.go new file mode 100644 index 00000000..69a36049 --- /dev/null +++ b/pkg/nanobot/mcp/httpclient.go @@ -0,0 +1,669 @@ +package mcp + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" + "github.com/gptscript-ai/gptscript/pkg/nanobot/uuid" +) + +const SessionIDHeader = "Mcp-Session-Id" + +type HTTPClient struct { + ctx context.Context + cancel context.CancelFunc + clientLock sync.RWMutex + httpClient *http.Client + handler WireHandler + oauthHandler *oauth + baseURL string + messageURL string + serverName string + headers map[string]string + waiter *waiter + sse bool + + initializeLock sync.RWMutex + initializeRequest *Message + sessionID *string + + sseLock sync.RWMutex + needReconnect bool +} + +func newHTTPClient(serverName, baseURL, oauthClientName, oauthRedirectURL string, callbackHandler CallbackHandler, clientCredLookup ClientCredLookup, tokenStorage TokenStorage, headers map[string]string) *HTTPClient { + var sessionID *string + if id := headers[SessionIDHeader]; id != "" { + sessionID = &id + } + h := &HTTPClient{ + httpClient: http.DefaultClient, + oauthHandler: newOAuth(callbackHandler, clientCredLookup, tokenStorage, oauthClientName, oauthRedirectURL), + baseURL: baseURL, + messageURL: baseURL, + serverName: serverName, + headers: maps.Clone(headers), + waiter: newWaiter(), + needReconnect: true, + sessionID: sessionID, + } + + return h +} + +func (s *HTTPClient) SetOAuthCallbackHandler(handler CallbackHandler) { + s.oauthHandler.callbackHandler = handler +} + +func (s *HTTPClient) SessionID() string { + s.initializeLock.RLock() + defer s.initializeLock.RUnlock() + + if s.sessionID == nil { + return "" + } + return *s.sessionID +} + +func (s *HTTPClient) Close(deleteSession bool) { + if deleteSession { + s.initializeLock.RLock() + sessionID := s.sessionID + s.initializeLock.RUnlock() + + if sessionID != nil && *sessionID != "" { + // If we have a session ID, then we need to send a close message to + // the server to clean up the session. + s.clientLock.RLock() + httpClient := s.httpClient + s.clientLock.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, err := s.newRequest(ctx, http.MethodDelete, nil) + if err != nil { + log.Errorf(ctx, "failed to create close request: %v", err) + return + } + + resp, err := httpClient.Do(req) + if err != nil { + // Best effort + log.Errorf(ctx, "failed to send close request: %v", err) + return + } + resp.Body.Close() + } + } + + if s.cancel != nil { + s.cancel() + } + s.waiter.Close() +} + +func (s *HTTPClient) Wait() { + s.waiter.Wait() +} + +func (s *HTTPClient) newRequest(ctx context.Context, method string, in any) (*http.Request, error) { + var body io.Reader + if in != nil { + data, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("failed to marshal message: %w", err) + } + body = bytes.NewBuffer(data) + log.Messages(ctx, s.serverName, true, data) + } + + u := s.messageURL + if method == http.MethodGet || u == "" { + // If this is a GET request, then it is starting the SSE stream. + // In this case, we need to use the base URL instead. + u = s.baseURL + } + + req, err := http.NewRequestWithContext(ctx, method, u, body) + if err != nil { + return nil, err + } + + for k, v := range s.headers { + req.Header.Set(k, v) + } + + s.initializeLock.RLock() + if s.sessionID != nil && *s.sessionID != "" { + req.Header.Set(SessionIDHeader, *s.sessionID) + } + s.initializeLock.RUnlock() + + req.Header.Set("Accept", "text/event-stream") + if method != http.MethodGet { + // Don't add because some *cough* CloudFront *cough* proxies don't like it + req.Header.Set("Accept", "application/json, text/event-stream") + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil +} + +func (s *HTTPClient) ensureSSE(ctx context.Context, msg *Message, lastEventID any) error { + s.sseLock.RLock() + if !s.needReconnect { + s.sseLock.RUnlock() + return nil + } + s.sseLock.RUnlock() + + // Hold the lock while we try to start the SSE endpoint. + // We need to make sure that the message URL is set before continuing. + s.sseLock.Lock() + defer s.sseLock.Unlock() + + if !s.needReconnect { + // Check again in case SSE was started while we were waiting for the lock. + return nil + } + + // Start the SSE stream with the managed context. + req, err := s.newRequest(s.ctx, http.MethodGet, nil) + if err != nil { + return err + } + + if lastEventID != nil { + req.Header.Set("Last-Event-ID", fmt.Sprintf("%v", lastEventID)) + } + + s.clientLock.RLock() + httpClient := s.httpClient + s.clientLock.RUnlock() + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode == http.StatusUnauthorized { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return AuthRequiredErr{ + ProtectedResourceValue: resp.Header.Get("WWW-Authenticate"), + Err: fmt.Errorf("failed to connect to SSE server: %s: %s", resp.Status, body), + } + } + + if resp.StatusCode == http.StatusNotFound && !s.sse { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + + s.initializeLock.RLock() + defer s.initializeLock.RUnlock() + + return SessionNotFoundErr{ + SessionID: *s.sessionID, + Err: fmt.Errorf("failed to connect to SSE server: %s: %s", resp.Status, body), + } + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + // If msg is nil, then this is an SSE request for HTTP streaming. + // If the server doesn't support a separate SSE endpoint, then we can just return. + if !s.sse && resp.StatusCode == http.StatusMethodNotAllowed { + s.needReconnect = false + return nil + } + return fmt.Errorf("failed to connect to SSE server url %s: %s, %s", req.URL.String(), resp.Status, string(body)) + } + + s.needReconnect = false + + gotResponse := make(chan error, 1) + go func() (err error, send bool) { + defer func() { + if err != nil { + s.sseLock.Lock() + s.needReconnect = true + s.sseLock.Unlock() + + // If we get an error, then we aren't reconnecting to the SSE endpoint. + if send { + gotResponse <- err + } + } + + resp.Body.Close() + }() + + messages := newSSEStream(resp.Body) + + if s.sse { + data, ok := messages.readNextMessage() + if !ok { + return fmt.Errorf("failed to read SSE message: %w", messages.err()), true + } + + baseURL, err := url.Parse(s.baseURL) + if err != nil { + return fmt.Errorf("failed to parse SSE URL: %w", err), true + } + + u, err := url.Parse(data) + if err != nil { + return fmt.Errorf("failed to parse returned SSE URL: %w", err), true + } + + baseURL.Path = u.Path + baseURL.RawQuery = u.RawQuery + s.messageURL = baseURL.String() + + initReq, err := s.newRequest(ctx, http.MethodPost, msg) + if err != nil { + return fmt.Errorf("failed to create initialize message req: %w", err), true + } + + s.clientLock.RLock() + httpClient = s.httpClient + s.clientLock.RUnlock() + + initResp, err := httpClient.Do(initReq) + if err != nil { + return fmt.Errorf("failed to POST initialize message: %w", err), true + } + body, _ := io.ReadAll(initResp.Body) + _ = initResp.Body.Close() + + if initResp.StatusCode != http.StatusOK && initResp.StatusCode != http.StatusAccepted { + return fmt.Errorf("failed to POST initialize message got status: %s: %s", initResp.Status, body), true + } + + // Mark this client as initialized. + s.initializeLock.Lock() + s.sessionID = new(string) + s.initializeRequest = msg + s.initializeLock.Unlock() + } + + close(gotResponse) + + for { + message, ok := messages.readNextMessage() + if !ok { + if err := messages.err(); err != nil { + if errors.Is(err, context.Canceled) { + log.Debugf(ctx, "context canceled reading SSE message: %v", messages.err()) + } else { + log.Errorf(ctx, "failed to read SSE message: %v", messages.err()) + } + } + + select { + case <-s.ctx.Done(): + // If the context is done, then we don't need to reconnect. + // Returning the error here will close the waiter, indicating that + // the client is done. + return s.ctx.Err(), false + default: + if msg != nil { + msg.ID = uuid.String() + } + s.sseLock.Lock() + if !s.needReconnect { + s.needReconnect = true + } + s.sseLock.Unlock() + } + + if err := s.ensureSSE(ctx, msg, lastEventID); err != nil { + return fmt.Errorf("failed to reconnect to SSE server: %v", err), false + } + + return nil, false + } + + var msg Message + if err := json.Unmarshal([]byte(message), &msg); err != nil { + continue + } + + if msg.ID != nil { + lastEventID = msg.ID + } + + log.Messages(ctx, s.serverName, false, []byte(message)) + s.handler(s.ctx, msg) + } + }() + + return <-gotResponse +} + +func (s *HTTPClient) Start(ctx context.Context, handler WireHandler) error { + s.ctx, s.cancel = context.WithCancel(ctx) + s.handler = handler + + if httpClient := s.oauthHandler.loadFromStorage(s.ctx, s.baseURL); httpClient != nil { + s.httpClient = httpClient + } + + return nil +} + +func (s *HTTPClient) initialize(ctx context.Context, msg Message) error { + req, err := s.newRequest(ctx, http.MethodPost, msg) + if err != nil { + return err + } + + s.clientLock.RLock() + httpClient := s.httpClient + s.clientLock.RUnlock() + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + streamingErrorMessage, _ := io.ReadAll(resp.Body) + return AuthRequiredErr{ + ProtectedResourceValue: resp.Header.Get("WWW-Authenticate"), + Err: fmt.Errorf("failed to initialize HTTP Streaming client: %s: %s", resp.Status, streamingErrorMessage), + } + } + + if resp.StatusCode != http.StatusOK { + streamingErrorMessage, _ := io.ReadAll(resp.Body) + streamError := fmt.Errorf("failed to initialize HTTP Streaming client: %s: %s", resp.Status, streamingErrorMessage) + + s.sse = true + if err := s.ensureSSE(ctx, &msg, nil); err != nil { + s.sse = false + return errors.Join(streamError, err) + } + + // The client is marked as initialized in ensureSSE after it receives a successful response to the initialize request + // to avoid a race with marking the client as initialized here and sending the notifications/initialized message. + return nil + } + + sessionID := resp.Header.Get(SessionIDHeader) + + s.initializeLock.Lock() + s.sessionID = &sessionID + s.initializeRequest = &msg + s.initializeLock.Unlock() + + go func() { + if err = s.ensureSSE(ctx, nil, nil); err != nil { + log.Errorf(context.Background(), "failed to initialize SSE: %v", err) + } + }() + + seen, err := s.readResponse(resp) + if err != nil { + return fmt.Errorf("failed to decode mcp initialize response: %w", err) + } else if !seen { + return fmt.Errorf("no response from server, expected an initialize response") + } + + return nil +} + +func (s *HTTPClient) Send(ctx context.Context, msg Message) error { + err := s.send(ctx, msg) + if err == nil { + return nil + } + + // We need to check for various errors and handle them according the spec. + + // Check for an authentication-required error and put the user through the OAuth process. + var oauthErr AuthRequiredErr + if errors.As(err, &oauthErr) { + httpClient, err := s.oauthHandler.oauthClient(s.ctx, s, s.baseURL, oauthErr.ProtectedResourceValue) + if err != nil || httpClient == nil { + streamError := fmt.Errorf("failed to initialize HTTP Streaming client: %w", oauthErr) + return errors.Join(streamError, err) + } + + s.clientLock.Lock() + s.httpClient = httpClient + s.clientLock.Unlock() + + // Make the call to send instead of Send so we don't get stuck in an authentication loop. + return s.send(ctx, msg) + } + + // Check for a session-not-found error and re-initialize. + var sessionNotFoundErr SessionNotFoundErr + if errors.As(err, &sessionNotFoundErr) && sessionNotFoundErr.SessionID != "" { + s.initializeLock.Lock() + s.sessionID = nil + s.initializeLock.Unlock() + + // Make the call to send instead of Send so we don't get stuck in a reinitialize loop. + return s.send(ctx, msg) + } + + // This loop checks for errors from the oauth2 package we use for the HTTP client after authentication. + // This is meant to catch errors such as failing to refresh the OAuth token. + unwrappedErr := err + for unwrappedErr != nil { + // Continually unwrap the errors until we find one that starts with oauth2: + if strings.HasPrefix(unwrappedErr.Error(), "oauth2:") { + // If we do find an error that starts with "oauth2:" then there was an issue with the oauth2 HTTP client. + // Reset the HTTP client to the default and try again. Using the default client will give us the unauthenticated + // error that we need to continue the process. + + s.clientLock.Lock() + s.httpClient = http.DefaultClient + s.clientLock.Unlock() + + // Use the exported Send method here so that we catch the AuthRequiredErr above on the recursed call. + return s.Send(ctx, msg) + } + unwrappedErr = errors.Unwrap(unwrappedErr) + } + + return err +} + +func (s *HTTPClient) send(ctx context.Context, msg Message) error { + s.initializeLock.RLock() + initialized := s.sessionID != nil + initializeMessage := s.initializeRequest + s.initializeLock.RUnlock() + + if !initialized { + if msg.Method != "initialize" && initializeMessage == nil { + return fmt.Errorf("cannot send %s message because client is not initialized, must send InitializeRequest first", msg.Method) + } + + if initializeMessage == nil { + initializeMessage = &msg + } else { + initializeMessage.ID = uuid.String() + } + if err := s.initialize(ctx, *initializeMessage); err != nil { + return fmt.Errorf("failed to initialize client: %w", err) + } + + if msg.Method == "initialize" { + // If we're sending the request to initialize, then we're done. + // Otherwise, we're reinitializing and should continue. + return nil + } else if err := s.send(ctx, Message{ + JSONRPC: "2.0", + Method: "notifications/initialized", + }); err != nil { + return fmt.Errorf("failed to send notifications/initialized: %w", err) + } + } + + errChan := make(chan error, 1) + go func() { + defer close(errChan) + // Ensure that the SSE connection is still active. + if err := s.ensureSSE(ctx, initializeMessage, nil); err != nil { + errChan <- fmt.Errorf("failed to restart SSE: %w", err) + } + }() + + if s.sse { + // If this is an SSE-based MCP server, then we have to wait for the SSE connection to be established. + if err := <-errChan; err != nil { + return err + } + } else { + // If not, then keep going. It will reconnect, if necessary. + go func() { + if err := <-errChan; err != nil { + log.Errorf(ctx, "failed to restart SSE: %v", err) + } + }() + } + + req, err := s.newRequest(ctx, http.MethodPost, msg) + if err != nil { + return err + } + + s.clientLock.RLock() + httpClient := s.httpClient + s.clientLock.RUnlock() + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + streamingErrorMessage, _ := io.ReadAll(resp.Body) + return AuthRequiredErr{ + ProtectedResourceValue: resp.Header.Get("WWW-Authenticate"), + Err: fmt.Errorf("failed to send message: %s: %s", resp.Status, streamingErrorMessage), + } + } + + if resp.StatusCode == http.StatusNotFound { + streamingErrorMessage, _ := io.ReadAll(resp.Body) + return SessionNotFoundErr{ + SessionID: req.Header.Get(SessionIDHeader), + Err: fmt.Errorf("failed to send message: %s: %s", resp.Status, streamingErrorMessage), + } + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("failed to send message: %s", resp.Status) + } + + if s.sse || resp.StatusCode == http.StatusAccepted { + return nil + } + + _, err = s.readResponse(resp) + return err +} + +func (s *HTTPClient) readResponse(resp *http.Response) (bool, error) { + var seen bool + handle := func(message *Message) { + seen = true + log.Messages(s.ctx, s.serverName, false, message.Result) + go s.handler(s.ctx, *message) + } + + if resp.Header.Get("Content-Type") == "text/event-stream" { + stream := newSSEStream(resp.Body) + for { + data, ok := stream.readNextMessage() + if !ok { + return seen, nil + } + + var message Message + if err := json.Unmarshal([]byte(data), &message); err != nil { + return seen, fmt.Errorf("failed to decode response: %w", err) + } + + handle(&message) + } + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return seen, fmt.Errorf("failed to read response body: %w", err) + } + + if len(data) == 0 { + return false, nil + } + + if data[0] != '{' { + return false, fmt.Errorf("invalid response format, expected JSON object, got: %s", data) + } + + var message Message + if err := json.Unmarshal(data, &message); err != nil { + return false, fmt.Errorf("failed to decode response: %w", err) + } + + handle(&message) + return seen, nil +} + +type SSEStream struct { + lines *bufio.Scanner +} + +func newSSEStream(input io.Reader) *SSEStream { + lines := bufio.NewScanner(input) + lines.Buffer(make([]byte, 0, 1024), 10*1024*1024) + return &SSEStream{ + lines: lines, + } +} + +func (s *SSEStream) err() error { + return s.lines.Err() +} + +func (s *SSEStream) readNextMessage() (string, bool) { + var eventName string + for s.lines.Scan() { + line := s.lines.Text() + if len(line) == 0 { + eventName = "" + continue + } + + if strings.HasPrefix(line, "event:") { + eventName = strings.TrimSpace(line[6:]) + } else if strings.HasPrefix(line, "data:") && (eventName == "message" || eventName == "" || eventName == "endpoint") { + return strings.TrimSpace(line[5:]), true + } + } + + return "", false +} diff --git a/pkg/nanobot/mcp/httpserver.go b/pkg/nanobot/mcp/httpserver.go new file mode 100644 index 00000000..6843b46b --- /dev/null +++ b/pkg/nanobot/mcp/httpserver.go @@ -0,0 +1,390 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net/http" + "strings" + "sync" + "time" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/complete" + "github.com/gptscript-ai/gptscript/pkg/nanobot/uuid" +) + +type HTTPServer struct { + env map[string]string + MessageHandler MessageHandler + sessions SessionStore + ctx context.Context + healthzPath string + + // internal health check state + internalSession *ServerSession + healthErr *error + healthMu sync.RWMutex +} + +type HTTPServerOptions struct { + SessionStore SessionStore + BaseContext context.Context + HealthzPath string +} + +func (h HTTPServerOptions) Complete() HTTPServerOptions { + if h.SessionStore == nil { + h.SessionStore = NewInMemorySessionStore() + } + if h.BaseContext == nil { + h.BaseContext = context.Background() + } + return h +} + +func (h HTTPServerOptions) Merge(other HTTPServerOptions) (result HTTPServerOptions) { + h.SessionStore = complete.Last(h.SessionStore, other.SessionStore) + h.BaseContext = complete.Last(h.BaseContext, other.BaseContext) + h.HealthzPath = complete.Last(h.HealthzPath, other.HealthzPath) + return h +} + +func NewHTTPServer(env map[string]string, handler MessageHandler, opts ...HTTPServerOptions) *HTTPServer { + o := complete.Complete(opts...) + h := &HTTPServer{ + MessageHandler: handler, + env: env, + sessions: o.SessionStore, + ctx: o.BaseContext, + healthzPath: o.HealthzPath, + } + + if h.healthzPath != "" { + go h.runHealthTicker() + } + + return h +} + +func (h *HTTPServer) streamEvents(rw http.ResponseWriter, req *http.Request) { + id := h.sessions.ExtractID(req) + if id == "" { + id = req.URL.Query().Get("id") + } + + if id == "" { + http.Error(rw, "Session ID is required", http.StatusBadRequest) + return + } + + session, ok, err := h.sessions.Load(req, id) + if err != nil { + http.Error(rw, "Failed to load session: "+err.Error(), http.StatusInternalServerError) + return + } + if !ok { + http.Error(rw, "Session not found", http.StatusNotFound) + return + } + + rw.Header().Set("Content-Type", "text/event-stream") + rw.Header().Set("Cache-Control", "no-cache") + rw.Header().Set("Connection", "keep-alive") + rw.WriteHeader(http.StatusOK) + if flusher, ok := rw.(http.Flusher); ok { + flusher.Flush() + } + + session.StartReading() + defer session.StopReading() + + for { + msg, ok := session.Read(req.Context()) + if !ok { + return + } + + data, _ := json.Marshal(msg) + _, err := rw.Write([]byte("data: " + string(data) + "\n\n")) + if err != nil { + http.Error(rw, "Failed to write message: "+err.Error(), http.StatusInternalServerError) + return + } + if f, ok := rw.(http.Flusher); ok { + f.Flush() + } + } +} + +func (h *HTTPServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodGet { + if h.healthzPath != "" && req.URL.Path == h.healthzPath { + h.healthMu.RLock() + healthErr := h.healthErr + h.healthMu.RUnlock() + + if healthErr == nil { + http.Error(rw, "waiting for startup", http.StatusTooEarly) + } else if *healthErr != nil { + http.Error(rw, (*healthErr).Error(), http.StatusServiceUnavailable) + } else { + rw.WriteHeader(http.StatusOK) + } + return + } + + h.streamEvents(rw, req) + return + } + + streamingID := h.sessions.ExtractID(req) + sseID := req.URL.Query().Get("id") + + if streamingID != "" && req.Method == http.MethodDelete { + sseSession, ok, err := h.sessions.LoadAndDelete(req, streamingID) + if err != nil { + http.Error(rw, "Failed to delete session: "+err.Error(), http.StatusInternalServerError) + return + } + if !ok { + http.Error(rw, "Session not found", http.StatusNotFound) + return + } + + sseSession.Close(true) + rw.WriteHeader(http.StatusNoContent) + return + } + + if req.Method != http.MethodPost { + http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var msg Message + if err := json.NewDecoder(req.Body).Decode(&msg); err != nil { + http.Error(rw, "Failed to decode message: "+err.Error(), http.StatusBadRequest) + return + } + + if streamingID != "" { + streamingSession, ok, err := h.sessions.Load(req, streamingID) + if err != nil { + http.Error(rw, "Failed to load session: "+err.Error(), http.StatusInternalServerError) + return + } + if !ok { + http.Error(rw, "Session not found", http.StatusNotFound) + return + } + + maps.Copy(streamingSession.session.EnvMap(), h.getEnv(req)) + + response, err := streamingSession.Exchange(req.Context(), msg) + if errors.Is(err, ErrNoResponse) { + rw.WriteHeader(http.StatusAccepted) + return + } else if err != nil { + response = Message{ + JSONRPC: msg.JSONRPC, + ID: msg.ID, + Error: ErrRPCInternal.WithMessage("%s", err.Error()), + } + } + + rw.Header().Set("Content-Type", "application/json") + + if len(response.Result) <= 2 && response.Error == nil && strings.HasPrefix(msg.Method, "notifications/") { + // Response has no data, write status accepted. + rw.WriteHeader(http.StatusAccepted) + } + + if err := json.NewEncoder(rw).Encode(response); err != nil { + http.Error(rw, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + } + + _ = h.sessions.Store(req, streamingSession.ID(), streamingSession) + return + } else if sseID != "" { + sseSession, ok, err := h.sessions.Load(req, sseID) + if err != nil { + http.Error(rw, "Failed to load session: "+err.Error(), http.StatusInternalServerError) + return + } + if !ok { + http.Error(rw, "Session not found", http.StatusNotFound) + return + } + + maps.Copy(sseSession.session.EnvMap(), h.getEnv(req)) + + if err := sseSession.Send(req.Context(), msg); err != nil { + http.Error(rw, "Failed to handle message: "+err.Error(), http.StatusInternalServerError) + return + } + + rw.WriteHeader(http.StatusAccepted) + return + } + + if msg.Method != "initialize" { + http.Error(rw, fmt.Sprintf("Method %q not allowed", msg.Method), http.StatusMethodNotAllowed) + return + } + + session, err := NewServerSession(h.ctx, h.MessageHandler) + if err != nil { + http.Error(rw, "Failed to create session: "+err.Error(), http.StatusInternalServerError) + return + } + + maps.Copy(session.session.EnvMap(), h.getEnv(req)) + + resp, err := session.Exchange(req.Context(), msg) + if err != nil { + http.Error(rw, "Failed to handle message: "+err.Error(), http.StatusInternalServerError) + return + } + + if err := h.sessions.Store(req, session.ID(), session); err != nil { + http.Error(rw, "Failed to store session: "+err.Error(), http.StatusInternalServerError) + return + } + + rw.Header().Set("Mcp-Session-Id", session.ID()) + rw.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(rw).Encode(resp); err != nil { + http.Error(rw, "Failed to encode response: "+err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *HTTPServer) runHealthTicker() { + ctx, cancel := context.WithTimeout(h.ctx, 2*time.Minute) + defer cancel() + err := h.checkTools(ctx) + + h.healthMu.Lock() + h.healthErr = &err + h.healthMu.Unlock() + + timer := time.NewTimer(time.Minute) + for { + ctx, cancel := context.WithTimeout(h.ctx, 30*time.Second) + err := h.checkTools(ctx) + cancel() + + h.healthMu.Lock() + h.healthErr = &err + h.healthMu.Unlock() + + timer.Reset(time.Minute) + select { + case <-h.ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} + +func (h *HTTPServer) ensureInternalSession(ctx context.Context) (*ServerSession, error) { + h.healthMu.RLock() + s := h.internalSession + h.healthMu.RUnlock() + if s != nil { + return s, nil + } + + session, err := NewServerSession(h.ctx, h.MessageHandler) + if err != nil { + return nil, err + } + // Set base environment on the internal session + maps.Copy(session.session.EnvMap(), h.env) + + // Initialize the session + if _, err := session.Exchange(ctx, Message{ + JSONRPC: "2.0", + ID: "healthz-initialize", + Method: "initialize", + Params: []byte(`{"capabilities":{},"clientInfo":{"name":"nanobot-internal"},"protocolVersion":"2025-06-18"}`), + }); err != nil { + session.Close(true) + return nil, fmt.Errorf("initialize failed: %w", err) + } + + // Send the initialized notification + if err = session.Send(ctx, Message{ + JSONRPC: "2.0", + Method: "notifications/initialized", + }); err != nil { + return nil, fmt.Errorf("failed to send initialized notification: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/mcp", nil) + if err != nil { + session.Close(true) + return nil, fmt.Errorf("failed to create request: %w", err) + } + h.sessions.Store(req, session.ID(), session) + + h.healthMu.Lock() + if s = h.internalSession; s != nil { + h.healthMu.Unlock() + // If another goroutine already set the internal session, close this one. + session.Close(true) + return s, nil + } + h.internalSession = session + h.healthMu.Unlock() + + return session, nil +} + +func (h *HTTPServer) checkTools(ctx context.Context) error { + session, err := h.ensureInternalSession(ctx) + if err != nil { + return err + } + + resp, err := session.Exchange(ctx, Message{ + JSONRPC: "2.0", + ID: uuid.String(), + Method: "tools/list", + Params: []byte(`{}`), + }) + if err != nil { + return err + } + if resp.Error != nil { + return fmt.Errorf("tools/list error: %s", resp.Error.Message) + } + + var out ListToolsResult + if err := json.Unmarshal(resp.Result, &out); err != nil { + return fmt.Errorf("failed to parse tools/list result: %w", err) + } + + if len(out.Tools) == 0 { + return fmt.Errorf("no tools from server") + } + return nil +} + +func (h *HTTPServer) getEnv(req *http.Request) map[string]string { + env := make(map[string]string) + maps.Copy(env, h.env) + token, ok := strings.CutPrefix(req.Header.Get("Authorization"), "Bearer ") + if ok { + env["http:bearer-token"] = token + } + for k, v := range req.Header { + if key, ok := strings.CutPrefix(k, "X-Nanobot-Env-"); ok { + env[key] = strings.Join(v, ", ") + } + } + return env +} diff --git a/pkg/nanobot/mcp/message.go b/pkg/nanobot/mcp/message.go new file mode 100644 index 00000000..c4f68e43 --- /dev/null +++ b/pkg/nanobot/mcp/message.go @@ -0,0 +1,183 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" +) + +type Message struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method,omitempty"` + Params json.RawMessage `json:"params,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` + + Session *Session `json:"-"` +} + +func NewMessage(method string, params any) (*Message, error) { + msg := &Message{ + JSONRPC: "2.0", + Method: method, + } + if params != nil { + data, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("failed to marshal params: %w", err) + } + msg.Params = data + } + return msg, nil +} + +func (r *Message) IsRequest() bool { + return len(r.Params) > 0 && !bytes.Equal(r.Params, []byte("null")) +} + +func (r *Message) SetProgressToken(token any) error { + params := map[string]any{} + if len(r.Params) > 0 { + if err := json.Unmarshal(r.Params, ¶ms); err != nil { + return fmt.Errorf("failed to unmarshal params to set progress token: %w", err) + } + } + + meta, ok := params["_meta"].(map[string]any) + if !ok { + meta = make(map[string]any) + } + + meta["progressToken"] = token + params["_meta"] = meta + data, err := json.Marshal(params) + if err != nil { + return fmt.Errorf("failed to marshal params to set progress token: %w", err) + } + + r.Params = data + return nil +} + +func (r *Message) ProgressToken() any { + if len(r.Params) == 0 || !bytes.Contains(r.Params, []byte("progressToken")) { + return nil + } + var token struct { + Meta struct { + ProgressToken any `json:"progressToken"` + } `json:"_meta"` + } + if err := json.Unmarshal(r.Params, &token); err == nil && token.Meta.ProgressToken != nil { + return token.Meta.ProgressToken + } + return nil +} + +func (r *Message) UID(sessionID string, in bool) string { + parts := strings.Split(sessionID, "/") + sessionID, _, _ = strings.Cut(parts[len(parts)-1], "::") + + var ( + id = fmt.Sprintf("%v", r.ID) + direction = "out" + ) + if in { + direction = "in" + } + return fmt.Sprintf("%s::%s::%s", sessionID, id, direction) +} + +func (r *Message) SendError(ctx context.Context, err error) { + if r.Session == nil { + return + } + var data *RPCError + if rpcError := (JSONRPCError)(nil); errors.As(err, &rpcError) { + data = rpcError.RPCError() + } else { + data = ErrRPCInternal.WithMessage("%s", err.Error()) + } + + resp := Message{ + JSONRPC: r.JSONRPC, + ID: r.ID, + Error: data, + } + + if err := r.Session.Send(ctx, resp); err != nil { + log.Errorf(ctx, "failed to send error response: %v", err) + } +} + +func (r *Message) Reply(ctx context.Context, result any) error { + data, err := json.Marshal(result) + if err != nil { + return fmt.Errorf("failed to marshal result: %w", err) + } + return r.Session.Send(ctx, Message{ + JSONRPC: r.JSONRPC, + ID: r.ID, + Result: data, + }) +} + +type JSONRPCError interface { + RPCError() *RPCError +} + +var ( + ErrRPCParse = NewRPCError(-32700, "JSON RPC parse error") + ErrRPCInvalidRequest = NewRPCError(-32600, "JSON RPC invalid request") + ErrRPCMethodNotFound = NewRPCError(-32601, "JSON RPC method not found") + ErrRPCInvalidParams = NewRPCError(-32602, "JSON RPC invalid params") + ErrRPCInternal = NewRPCError(-32603, "JSON RPC internal error") +) + +type RPCError struct { + Code int `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + DataObject any `json:"-"` +} + +func NewRPCError(code int, message string) *RPCError { + return &RPCError{ + Code: code, + Message: message, + } +} + +func (e *RPCError) WithMessage(fmtStr string, args ...any) *RPCError { + cp := *e + cp.Message += ": " + fmt.Sprintf(fmtStr, args...) + return &cp +} + +func (e *RPCError) RPCError() *RPCError { + if e == nil { + return nil + } + if e.DataObject != nil { + result := *e + result.Data, _ = json.Marshal(e.DataObject) + return &result + } + return e +} + +func (e *RPCError) Error() string { + if e == nil { + return "nil error" + } + if e.Data != nil { + return fmt.Sprintf("%d: %s (%s)", e.Code, e.Message, string(e.Data)) + } + return fmt.Sprintf("%d: %s", e.Code, e.Message) +} diff --git a/pkg/nanobot/mcp/oauth.go b/pkg/nanobot/mcp/oauth.go new file mode 100644 index 00000000..a5cbd173 --- /dev/null +++ b/pkg/nanobot/mcp/oauth.go @@ -0,0 +1,667 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" + "golang.org/x/oauth2" +) + +var resourceMetadataRegex = regexp.MustCompile(`resource_metadata="([^"]*)"`) + +type oauth struct { + redirectURL, clientName string + currentToken oauth2.Token + metadataClient *http.Client + callbackHandler CallbackHandler + clientLookup ClientCredLookup + tokenStorage TokenStorage +} + +func newOAuth(callbackHandler CallbackHandler, clientLookup ClientCredLookup, tokenStorage TokenStorage, clientName, redirectURL string) *oauth { + return &oauth{ + clientName: clientName, + redirectURL: redirectURL, + callbackHandler: callbackHandler, + metadataClient: &http.Client{ + Timeout: 5 * time.Second, + }, + clientLookup: clientLookup, + tokenStorage: tokenStorage, + } +} + +func (o *oauth) loadFromStorage(ctx context.Context, connectURL string) *http.Client { + if o.tokenStorage == nil { + return nil + } + + // Read the token config from storage to see if we have valid auth + conf, tok, err := o.tokenStorage.GetTokenConfig(ctx, connectURL) + if err != nil { + log.Infof(ctx, "failed to read token config: %v", err) + log.Infof(ctx, "continuing with authentication") + } + + if conf != nil && tok != nil { + ts := newTokenSource(ctx, o.tokenStorage, connectURL, conf, tok) + tok, err = ts.Token() + if err == nil && tok.Valid() { + o.currentToken = *tok + return oauth2.NewClient(ctx, ts) + } + } + + return nil +} + +func (o *oauth) oauthClient(ctx context.Context, c *HTTPClient, connectURL, authenticateHeader string) (*http.Client, error) { + if httpClient := o.loadFromStorage(ctx, connectURL); httpClient != nil { + return httpClient, nil + } + + if o.callbackHandler == nil || o.redirectURL == "" { + return nil, fmt.Errorf("oauth callback server is not configured") + } + + u, err := url.Parse(c.baseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse MCP URL: %w", err) + } + + var resourceMetadataURL string + if authenticateHeader != "" { + resourceMetadataURL = parseResourceMetadata(authenticateHeader) + } + if resourceMetadataURL == "" { + // If the authenticate header was not sent back or it did not have a resource metadata URL, then the spec says we should default to... + u.Path = "/.well-known/oauth-protected-resource" + resourceMetadataURL = u.String() + } + + // Get the protected resource metadata + protectedResourceResp, err := o.metadataClient.Get(resourceMetadataURL) + if err != nil { + return nil, fmt.Errorf("failed to get protected resource metadata: %w", err) + } + defer protectedResourceResp.Body.Close() + + var protectedResourceMetadata protectedResourceMetadata + if protectedResourceResp.StatusCode != http.StatusOK && protectedResourceResp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(protectedResourceResp.Body) + return nil, fmt.Errorf("unexpeted status getting protected resource metadata (%d): %s", protectedResourceResp.StatusCode, string(body)) + } else if protectedResourceResp.StatusCode == http.StatusOK { + protectedResourceMetadata, err = parseProtectedResourceMetadata(protectedResourceResp.Body) + if err != nil { + return nil, fmt.Errorf("failed to parse protected resource metadata: %w", err) + } + } + + if len(protectedResourceMetadata.AuthorizationServers) == 0 { + protectedResourceMetadata.AuthorizationServers = []string{fmt.Sprintf("%s://%s", u.Scheme, u.Host)} + } + + authorizationServerMetadata, err := o.getAuthServerMetadata(protectedResourceMetadata.AuthorizationServers[0]) + if err != nil { + return nil, fmt.Errorf("failed to get authorization server metadata: %w", err) + } + + clientMetadata := authServerMetadataToClientRegistration(authorizationServerMetadata) + clientMetadata.RedirectURIs = []string{o.redirectURL} + clientMetadata.ClientName = o.clientName + + b, err := json.Marshal(clientMetadata) + if err != nil { + return nil, fmt.Errorf("failed to marshal client metadata: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, authorizationServerMetadata.RegistrationEndpoint, bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("failed to create registration request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := o.metadataClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to register client: %w", err) + } + defer resp.Body.Close() + + var clientInfo clientRegistrationResponse + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden { + // If the registration endpoint produces a not found, then look for static client credentials. + clientInfo.ClientID, clientInfo.ClientSecret, err = o.clientLookup.Lookup(ctx, protectedResourceMetadata.AuthorizationServers[0]) + if err != nil { + return nil, fmt.Errorf("failed to lookup client credentials: %w", err) + } + if clientInfo.ClientID == "" { + return nil, fmt.Errorf("client registration failed with status %s and no client credentials were found", resp.Status) + } + } else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpeted status registering client (%d): %s", resp.StatusCode, string(body)) + } else { + clientInfo, err = parseClientRegistrationResponse(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to parse client registration response: %w", err) + } + } + + conf := &oauth2.Config{ + ClientID: clientInfo.ClientID, + ClientSecret: clientInfo.ClientSecret, + RedirectURL: clientMetadata.RedirectURIs[0], + Endpoint: oauth2.Endpoint{ + AuthURL: authorizationServerMetadata.AuthorizationEndpoint, + TokenURL: authorizationServerMetadata.TokenEndpoint, + }, + } + if clientMetadata.Scope != "" { + conf.Scopes = strings.Split(clientMetadata.Scope, " ") + } + switch clientMetadata.TokenEndpointAuthMethod { + case "client_secret_basic": + conf.Endpoint.AuthStyle = oauth2.AuthStyleInHeader + case "client_secret_post": + conf.Endpoint.AuthStyle = oauth2.AuthStyleInParams + default: + conf.Endpoint.AuthStyle = oauth2.AuthStyleAutoDetect + } + + // use PKCE to protect against CSRF attacks + // https://www.ietf.org/archive/id/draft-ietf-oauth-security-topics-22.html#name-countermeasures-6 + verifier := oauth2.GenerateVerifier() + + state, ch, err := o.callbackHandler.NewState(ctx, conf, verifier) + if err != nil { + return nil, fmt.Errorf("failed to create state: %w", err) + } + + // Redirect user to consent page to ask for permission + // for the scopes specified above. + authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) + handled, err := o.callbackHandler.HandleAuthURL(ctx, c.serverName, authURL) + if err != nil { + return nil, fmt.Errorf("failed to handle auth url %s: %w", authURL, err) + } else if !handled { + return nil, nil + } + + var cb CallbackPayload + select { + case <-ctx.Done(): + return nil, ctx.Err() + case cb = <-ch: + if cb.Error != "" { + return nil, fmt.Errorf("authorization failed: %s, %s", cb.Error, cb.ErrorDescription) + } + if cb.Code == "" { + return nil, fmt.Errorf("authorization failed: no code returned") + } + } + + tok, err := conf.Exchange(ctx, cb.Code, oauth2.VerifierOption(verifier)) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + o.currentToken = *tok + + if o.tokenStorage != nil { + if err = o.tokenStorage.SetTokenConfig(ctx, connectURL, conf, tok); err != nil { + log.Infof(ctx, "failed to save token config: %v", err) + } + } + + return oauth2.NewClient(ctx, newTokenSource(ctx, o.tokenStorage, connectURL, conf, tok)), nil +} + +func (o *oauth) getAuthServerMetadata(authURL string) (authorizationServerMetadata, error) { + authServerURL := strings.TrimSuffix(authURL, "/") + + authServerMetadata := authServerURL + // If the authServer URL has a path, then the well-known path is prepended to the path + if u, err := url.Parse(authServerMetadata); err != nil { + return authorizationServerMetadata{}, fmt.Errorf("failed to parse auth server URL: %w", err) + } else if u.Path != "" { + u.Path = "/.well-known/oauth-authorization-server" + u.Path + authServerMetadata = u.String() + } else { + authServerMetadata = fmt.Sprintf("%s/.well-known/oauth-authorization-server", authServerMetadata) + } + oauthMetadataResp, err := o.metadataClient.Get(authServerMetadata) + if err != nil { + return authorizationServerMetadata{}, fmt.Errorf("failed to get authorization server metadata: %w", err) + } + defer oauthMetadataResp.Body.Close() + + var authorizationServerMetadataContent authorizationServerMetadata + if oauthMetadataResp.StatusCode != http.StatusOK && oauthMetadataResp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(oauthMetadataResp.Body) + return authorizationServerMetadata{}, fmt.Errorf("unexpeted status getting authorization server metadata (%d): %s", oauthMetadataResp.StatusCode, string(body)) + } else if oauthMetadataResp.StatusCode == http.StatusOK { + authorizationServerMetadataContent, err = parseAuthorizationServerMetadata(oauthMetadataResp.Body) + if err != nil { + return authorizationServerMetadata{}, fmt.Errorf("failed to parse authorization server metadata: %w", err) + } + } else { + // We couldn't find the oauth-authorization-server endpoint, so look for the openid-configuration endpoint. + openIDConfigResp, err := o.metadataClient.Get(strings.Replace(authServerMetadata, "/.well-known/oauth-authorization-server", "/.well-known/openid-configuration", 1)) + if err != nil { + return authorizationServerMetadata{}, fmt.Errorf("failed to get openid-configuration: %w", err) + } + defer openIDConfigResp.Body.Close() + + if openIDConfigResp.StatusCode != http.StatusOK { + authorizationServerMetadataContent, err = parseAuthorizationServerMetadata(openIDConfigResp.Body) + if err != nil { + return authorizationServerMetadata{}, fmt.Errorf("failed to parse openid configuration: %w", err) + } + } else { + // The last URL we check is appending the openid-configuration path to the end. + openIDConfigResp, err := o.metadataClient.Get(strings.Replace(authServerMetadata, "/.well-known/oauth-authorization-server", "", 1) + "/.well-known/openid-configuration") + if err != nil { + return authorizationServerMetadata{}, fmt.Errorf("failed to get openid-configuration: %w", err) + } + defer openIDConfigResp.Body.Close() + + authorizationServerMetadataContent, err = parseAuthorizationServerMetadata(openIDConfigResp.Body) + if err != nil { + return authorizationServerMetadata{}, fmt.Errorf("failed to parse openid configuration: %w", err) + } + } + + } + + if authorizationServerMetadataContent.AuthorizationEndpoint == "" { + authorizationServerMetadataContent.AuthorizationEndpoint = fmt.Sprintf("%s/authorize", authServerURL) + } + if authorizationServerMetadataContent.TokenEndpoint == "" { + authorizationServerMetadataContent.TokenEndpoint = fmt.Sprintf("%s/token", authServerURL) + } + if authorizationServerMetadataContent.RegistrationEndpoint == "" { + authorizationServerMetadataContent.RegistrationEndpoint = fmt.Sprintf("%s/register", authServerURL) + } + + return authorizationServerMetadataContent, nil +} + +// parseAuthorizationServerMetadata parses OAuth 2.0 Authorization Server Metadata +// from a reader containing JSON data as defined in RFC 8414 +func parseAuthorizationServerMetadata(reader io.Reader) (authorizationServerMetadata, error) { + var metadata authorizationServerMetadata + if err := json.NewDecoder(reader).Decode(&metadata); err != nil { + return metadata, fmt.Errorf("failed to decode authorization server metadata: %w", err) + } + + // Validate required fields + if metadata.Issuer == "" { + return metadata, fmt.Errorf("issuer is required but not provided") + } + + if len(metadata.ResponseTypesSupported) == 0 { + return metadata, fmt.Errorf("response_types_supported is required but not provided") + } + + // Set default values for optional fields if not provided + if len(metadata.ResponseModesSupported) == 0 { + metadata.ResponseModesSupported = []string{"query", "fragment"} + } + + if len(metadata.GrantTypesSupported) == 0 { + metadata.GrantTypesSupported = []string{"authorization_code", "implicit"} + } + + if len(metadata.TokenEndpointAuthMethodsSupported) == 0 { + metadata.TokenEndpointAuthMethodsSupported = []string{"client_secret_basic"} + } + + if len(metadata.RevocationEndpointAuthMethodsSupported) == 0 { + metadata.RevocationEndpointAuthMethodsSupported = []string{"client_secret_basic"} + } + + return metadata, nil +} + +// parseProtectedResourceMetadata parses OAuth 2.0 Protected Resource Metadata +// from a reader containing JSON data as defined in RFC 8707 +func parseProtectedResourceMetadata(reader io.Reader) (protectedResourceMetadata, error) { + var metadata protectedResourceMetadata + if err := json.NewDecoder(reader).Decode(&metadata); err != nil { + return metadata, fmt.Errorf("failed to decode protected resource metadata: %w", err) + } + + // Validate required fields + if metadata.Resource == "" { + return metadata, fmt.Errorf("resource is required but not provided") + } + + // Set default values for optional fields if not provided + // According to RFC 8707, if bearer_methods_supported is omitted, no default bearer methods are implied + // The empty array [] can be used to indicate that no bearer methods are supported + // We don't set defaults here as the absence has specific meaning + + // Validate that resource_signing_alg_values_supported does not contain "none" + for _, alg := range metadata.ResourceSigningAlgValuesSupported { + if alg == "none" { + return metadata, fmt.Errorf("resource_signing_alg_values_supported must not contain 'none'") + } + } + + return metadata, nil +} + +// parseResourceMetadata extracts the resource_metadata URL from a Bearer authenticate header +func parseResourceMetadata(authenticateHeader string) string { + // Use regex to find resource_metadata parameter + // Pattern matches: resource_metadata="" + matches := resourceMetadataRegex.FindStringSubmatch(authenticateHeader) + + if len(matches) < 2 { + return "" + } + + return matches[1] +} + +// protectedResourceMetadata represents OAuth 2.0 Protected Resource Metadata +// as defined in RFC 8707 +type protectedResourceMetadata struct { + // REQUIRED. The protected resource's resource identifier + Resource string `json:"resource"` + + // OPTIONAL. JSON array containing a list of OAuth authorization server issuer identifiers + AuthorizationServers []string `json:"authorization_servers,omitempty"` + + // OPTIONAL. URL of the protected resource's JSON Web Key (JWK) Set document + JwksURI string `json:"jwks_uri,omitempty"` + + // RECOMMENDED. JSON array containing a list of scope values + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // OPTIONAL. JSON array containing a list of the supported methods of sending an OAuth 2.0 bearer token + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + + // OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the protected resource + ResourceSigningAlgValuesSupported []string `json:"resource_signing_alg_values_supported,omitempty"` + + // OPTIONAL. Human-readable name of the protected resource intended for display to the end user + ResourceName string `json:"resource_name,omitempty"` + + // OPTIONAL. URL of a page containing human-readable information that developers might want or need to know + ResourceDocumentation string `json:"resource_documentation,omitempty"` + + // OPTIONAL. URL of a page containing human-readable information about the protected resource's requirements + ResourcePolicyURI string `json:"resource_policy_uri,omitempty"` + + // OPTIONAL. URL of a page containing human-readable information about the protected resource's terms of service + ResourceTosURI string `json:"resource_tos_uri,omitempty"` + + // OPTIONAL. Boolean value indicating protected resource support for mutual-TLS client certificate-bound access tokens + TLSClientCertificateBoundAccessTokens bool `json:"tls_client_certificate_bound_access_tokens,omitempty"` + + // OPTIONAL. JSON array containing a list of the authorization details type values supported by the resource server + AuthorizationDetailsTypesSupported []string `json:"authorization_details_types_supported,omitempty"` + + // OPTIONAL. JSON array containing a list of the JWS alg values supported by the resource server for validating DPoP proof JWTs + DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + + // OPTIONAL. Boolean value specifying whether the protected resource always requires the use of DPoP-bound access tokens + DPoPBoundAccessTokensRequired bool `json:"dpop_bound_access_tokens_required,omitempty"` +} + +// authorizationServerMetadata represents OAuth 2.0 Authorization Server Metadata +// as defined in RFC 8414 +type authorizationServerMetadata struct { + // REQUIRED. The authorization server's issuer identifier + Issuer string `json:"issuer"` + + // URL of the authorization server's authorization endpoint + AuthorizationEndpoint string `json:"authorization_endpoint,omitempty"` + + // URL of the authorization server's token endpoint + TokenEndpoint string `json:"token_endpoint,omitempty"` + + // OPTIONAL. URL of the authorization server's JWK Set document + JwksURI string `json:"jwks_uri,omitempty"` + + // OPTIONAL. URL of the authorization server's OAuth 2.0 Dynamic Client Registration endpoint + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + + // RECOMMENDED. JSON array containing a list of the OAuth 2.0 scope values + ScopesSupported []string `json:"scopes_supported,omitempty"` + + // REQUIRED. JSON array containing a list of the OAuth 2.0 response_type values + ResponseTypesSupported []string `json:"response_types_supported"` + + // OPTIONAL. JSON array containing a list of the OAuth 2.0 response_mode values + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + + // OPTIONAL. JSON array containing a list of the OAuth 2.0 grant type values + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + // OPTIONAL. JSON array containing a list of client authentication methods + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + + // OPTIONAL. JSON array containing a list of the JWS signing algorithms + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + + // OPTIONAL. URL of a page containing human-readable information + ServiceDocumentation string `json:"service_documentation,omitempty"` + + // OPTIONAL. Languages and scripts supported for the user interface + UILocalesSupported []string `json:"ui_locales_supported,omitempty"` + + // OPTIONAL. URL for authorization server's requirements on client data usage + OpPolicyURI string `json:"op_policy_uri,omitempty"` + + // OPTIONAL. URL for authorization server's terms of service + OpTosURI string `json:"op_tos_uri,omitempty"` + + // OPTIONAL. URL of the authorization server's OAuth 2.0 revocation endpoint + RevocationEndpoint string `json:"revocation_endpoint,omitempty"` + + // OPTIONAL. JSON array containing client authentication methods for revocation endpoint + RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported,omitempty"` + + // OPTIONAL. JSON array containing JWS signing algorithms for revocation endpoint + RevocationEndpointAuthSigningAlgValuesSupported []string `json:"revocation_endpoint_auth_signing_alg_values_supported,omitempty"` + + // OPTIONAL. URL of the authorization server's OAuth 2.0 introspection endpoint + IntrospectionEndpoint string `json:"introspection_endpoint,omitempty"` + + // OPTIONAL. JSON array containing client authentication methods for introspection endpoint + IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported,omitempty"` + + // OPTIONAL. JSON array containing JWS signing algorithms for introspection endpoint + IntrospectionEndpointAuthSigningAlgValuesSupported []string `json:"introspection_endpoint_auth_signing_alg_values_supported,omitempty"` + + // OPTIONAL. JSON array containing PKCE code challenge methods + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` +} + +// clientRegistrationMetadata represents OAuth 2.0 Dynamic Client Registration metadata +// as defined in RFC 7591, merged from protected resource and authorization server metadata +type clientRegistrationMetadata struct { + // Array of redirection URI strings for use in redirect-based flows + RedirectURIs []string `json:"redirect_uris,omitempty"` + + // String indicator of the requested authentication method for the token endpoint + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + + // Array of OAuth 2.0 grant type strings that the client can use at the token endpoint + GrantTypes []string `json:"grant_types,omitempty"` + + // Array of the OAuth 2.0 response type strings that the client can use at the authorization endpoint + ResponseTypes []string `json:"response_types,omitempty"` + + // Human-readable string name of the client to be presented to the end-user during authorization + ClientName string `json:"client_name,omitempty"` + + // URL string of a web page providing information about the client + ClientURI string `json:"client_uri,omitempty"` + + // URL string that references a logo for the client + LogoURI string `json:"logo_uri,omitempty"` + + // String containing a space-separated list of scope values + Scope string `json:"scope,omitempty"` + + // Array of strings representing ways to contact people responsible for this client + Contacts []string `json:"contacts,omitempty"` + + // URL string that points to a human-readable terms of service document for the client + TosURI string `json:"tos_uri,omitempty"` + + // URL string that points to a human-readable privacy policy document + PolicyURI string `json:"policy_uri,omitempty"` + + // URL string referencing the client's JSON Web Key (JWK) Set document + JwksURI string `json:"jwks_uri,omitempty"` + + // Client's JSON Web Key Set document value + Jwks interface{} `json:"jwks,omitempty"` + + // A unique identifier string assigned by the client developer or software publisher + SoftwareID string `json:"software_id,omitempty"` + + // A version identifier string for the client software identified by "software_id" + SoftwareVersion string `json:"software_version,omitempty"` +} + +func authServerMetadataToClientRegistration(authServer authorizationServerMetadata) clientRegistrationMetadata { + merged := clientRegistrationMetadata{} + + // Set default values based on OAuth 2.0 specifications + + // token_endpoint_auth_method: default is "client_secret_basic" if not specified + if len(authServer.TokenEndpointAuthMethodsSupported) > 0 { + merged.TokenEndpointAuthMethod = authServer.TokenEndpointAuthMethodsSupported[0] + } else { + merged.TokenEndpointAuthMethod = "client_secret_basic" + } + + // grant_types: default is "authorization_code" if not specified + if len(authServer.GrantTypesSupported) > 0 { + merged.GrantTypes = authServer.GrantTypesSupported + } else { + merged.GrantTypes = []string{"authorization_code"} + } + + // response_types: default is "code" if not specified + if len(authServer.ResponseTypesSupported) > 0 { + merged.ResponseTypes = authServer.ResponseTypesSupported + } else { + merged.ResponseTypes = []string{"code"} + } + + // scope: combine scopes from both sources, preferring protected resource + if len(authServer.ScopesSupported) > 0 { + merged.Scope = strings.Join(authServer.ScopesSupported, " ") + } + // Note: redirect_uris, logo_uri, contacts, jwks, software_id, and software_version + // are typically client-specific and would need to be provided by the client application + // These fields are left empty as they cannot be derived from server metadata + + return merged +} + +// clientRegistrationResponse represents OAuth 2.0 Dynamic Client Registration Response +// as defined in RFC 7591 +type clientRegistrationResponse struct { + // REQUIRED. OAuth 2.0 client identifier string. It SHOULD NOT be + // currently valid for any other registered client, though an + // authorization server MAY issue the same client identifier to + // multiple instances of a registered client at its discretion. + ClientID string `json:"client_id"` + + // OPTIONAL. OAuth 2.0 client secret string. If issued, this MUST + // be unique for each "client_id" and SHOULD be unique for multiple + // instances of a client using the same "client_id". This value is + // used by confidential clients to authenticate to the token + // endpoint, as described in OAuth 2.0 [RFC6749], Section 2.3.1. + ClientSecret string `json:"client_secret,omitempty"` + + // OPTIONAL. Time at which the client identifier was issued. The + // time is represented as the number of seconds from + // 1970-01-01T00:00:00Z as measured in UTC until the date/time of + // issuance. + ClientIDIssuedAt *int64 `json:"client_id_issued_at,omitempty"` + + // REQUIRED if "client_secret" is issued. Time at which the client + // secret will expire or 0 if it will not expire. The time is + // represented as the number of seconds from 1970-01-01T00:00:00Z as + // measured in UTC until the date/time of expiration. + ClientSecretExpiresAt *int64 `json:"client_secret_expires_at,omitempty"` +} + +// parseClientRegistrationResponse parses OAuth 2.0 Dynamic Client Registration Response +// from a reader containing JSON data as defined in RFC 7591 +func parseClientRegistrationResponse(reader io.Reader) (clientRegistrationResponse, error) { + var response clientRegistrationResponse + if err := json.NewDecoder(reader).Decode(&response); err != nil { + return response, fmt.Errorf("failed to decode client registration response: %w", err) + } + + // Validate required fields + if response.ClientID == "" { + return response, fmt.Errorf("client_id is required but not provided") + } + + return response, nil +} + +// tokenSource implements the oauth2.TokenSource interface to store new tokens in the TokenStorage. +type tokenSource struct { + ctx context.Context + lock sync.Mutex + tokenStorage TokenStorage + connectURL string + conf *oauth2.Config + tok *oauth2.Token + tokenSource oauth2.TokenSource +} + +func newTokenSource(ctx context.Context, tokenStorage TokenStorage, connectURL string, conf *oauth2.Config, tok *oauth2.Token) oauth2.TokenSource { + return oauth2.ReuseTokenSource(tok, &tokenSource{ + ctx: ctx, + tokenStorage: tokenStorage, + connectURL: connectURL, + conf: conf, + tok: tok, + tokenSource: conf.TokenSource(ctx, tok), + }) +} + +func (ts *tokenSource) Token() (*oauth2.Token, error) { + tok, err := ts.tokenSource.Token() + if err != nil { + return nil, err + } + + ts.lock.Lock() + defer ts.lock.Unlock() + + if tok.AccessToken != ts.tok.AccessToken || tok.RefreshToken != ts.tok.RefreshToken || tok.Expiry.Unix() != ts.tok.Expiry.Unix() { + ts.tok = tok + + if ts.tokenStorage != nil { + if err = ts.tokenStorage.SetTokenConfig(ts.ctx, ts.connectURL, ts.conf, ts.tok); err != nil { + return nil, fmt.Errorf("failed to store token: %w", err) + } + } + } + + return ts.tok, nil +} diff --git a/pkg/nanobot/mcp/pendingrequest.go b/pkg/nanobot/mcp/pendingrequest.go new file mode 100644 index 00000000..a9021451 --- /dev/null +++ b/pkg/nanobot/mcp/pendingrequest.go @@ -0,0 +1,53 @@ +package mcp + +import "sync" + +type PendingRequests struct { + lock sync.Mutex + ids map[any]chan Message +} + +func (p *PendingRequests) WaitFor(id any) chan Message { + p.lock.Lock() + defer p.lock.Unlock() + if p.ids == nil { + p.ids = make(map[any]chan Message) + } + ch := make(chan Message, 1) + p.ids[id] = ch + return ch +} + +func (p *PendingRequests) Notify(msg Message) bool { + p.lock.Lock() + defer p.lock.Unlock() + + ch, ok := p.ids[msg.ID] + if ok { + select { + case ch <- msg: + return true + // don't let it block, we are holding the lock + default: + } + delete(p.ids, msg.ID) + } + return false +} + +func (p *PendingRequests) Done(id any) { + p.lock.Lock() + defer p.lock.Unlock() + + delete(p.ids, id) +} + +func (p *PendingRequests) Close() { + p.lock.Lock() + defer p.lock.Unlock() + + for _, ch := range p.ids { + close(ch) + } + p.ids = nil +} diff --git a/pkg/nanobot/mcp/runner.go b/pkg/nanobot/mcp/runner.go new file mode 100644 index 00000000..cd6dbb8a --- /dev/null +++ b/pkg/nanobot/mcp/runner.go @@ -0,0 +1,255 @@ +package mcp + +import ( + "context" + "fmt" + "io" + "maps" + "net" + "os" + "os/exec" + "slices" + "strings" + "sync" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/envvar" + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" + "github.com/gptscript-ai/gptscript/pkg/nanobot/mcp/sandbox" + "github.com/gptscript-ai/gptscript/pkg/nanobot/supervise" + "github.com/gptscript-ai/gptscript/pkg/nanobot/system" +) + +type Runner struct { + lock sync.Mutex + running map[string]Server +} + +type streamResult struct { + cmd *exec.Cmd + Stdout io.Reader + Stdin io.Writer + Close func() +} + +func (r *Runner) newCommand(ctx context.Context, currentEnv map[string]string, root func(context.Context) ([]Root, error), config Server) (Server, *sandbox.Cmd, error) { + var publishPorts []string + ports := config.Ports + if len(ports) == 0 { + // If no ports are specified, use the default port + ports = []string{"mcp"} + } + if currentEnv == nil { + currentEnv = make(map[string]string) + } else { + currentEnv = maps.Clone(currentEnv) + } + for _, port := range ports { + l, err := net.Listen("tcp4", "localhost:0") + if err != nil { + return config, nil, fmt.Errorf("failed to allocate port for %s: %w", port, err) + } + addrString := l.Addr().String() + _, portStr, err := net.SplitHostPort(addrString) + if err != nil { + _ = l.Close() + return config, nil, fmt.Errorf("failed to get port for %s, addr %s: %w", port, addrString, err) + } + if err := l.Close(); err != nil { + return config, nil, fmt.Errorf("failed to close listener for %s, addr %s: %w", port, addrString, err) + } + publishPorts = append(publishPorts, portStr) + currentEnv["port:"+port] = portStr + currentEnv["nanobot:port:"+port] = portStr + } + + config.BaseURL = envvar.ReplaceString(currentEnv, config.BaseURL) + + command, args, env := envvar.ReplaceEnv(currentEnv, config.Command, config.Args, config.Env) + if !config.Sandboxed || command == "nanobot" { + if command == "nanobot" { + command = system.Bin() + } + cmd := supervise.Cmd(ctx, command, args...) + cmd.Dir = envvar.ReplaceString(currentEnv, config.Cwd) + cmd.Env = append(cleanOSEnv(), env...) + return config, &sandbox.Cmd{ + Cmd: cmd, + }, nil + } + + var ( + rootPaths []sandbox.Root + roots []Root + err error + ) + + if root != nil { + roots, err = root(ctx) + if err != nil { + return config, nil, fmt.Errorf("failed to get roots: %w", err) + } + } + + for _, root := range roots { + if strings.HasPrefix(root.URI, "file://") { + rootPaths = append(rootPaths, sandbox.Root{ + Name: root.Name, + Path: root.URI[7:], + }) + } + } + + cmd, err := sandbox.NewCmd(ctx, sandbox.Command{ + PublishPorts: publishPorts, + ReversePorts: config.ReversePorts, + Roots: rootPaths, + Command: command, + Workdir: envvar.ReplaceString(config.Env, config.Workdir), + Args: args, + Env: slices.Collect(maps.Keys(config.Env)), + BaseImage: config.Image, + Dockerfile: config.Dockerfile, + Source: sandbox.Source(config.Source), + }) + if err != nil { + return config, nil, fmt.Errorf("failed to create sandbox command: %w", err) + } + + cmd.Env = append(cleanOSEnv(), env...) + return config, cmd, nil +} + +var allowedEnv = map[string]bool{ + "PATH": true, + "HOME": true, + "USER": true, +} + +func cleanOSEnv() []string { + // Clean up the environment variables to avoid issues with sandboxing + env := os.Environ() + cleanedEnv := make([]string, 0, len(allowedEnv)) + for _, e := range env { + k, _, found := strings.Cut(e, "=") + if found && allowedEnv[k] { + // Only allow specific environment variables + cleanedEnv = append(cleanedEnv, e) + } + } + return cleanedEnv +} + +func (r *Runner) doRun(ctx context.Context, serverName string, config Server, cmd *sandbox.Cmd) (Server, error) { + // hold open stdin for the supervisor + _, err := cmd.StdinPipe() + if err != nil { + return config, fmt.Errorf("failed to get stdin pipe: %w", err) + } + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return config, fmt.Errorf("failed to get stdout pipe: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return config, fmt.Errorf("failed to get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return config, fmt.Errorf("failed to start command: %w", err) + } + + if r.running == nil { + r.running = make(map[string]Server) + } + r.running[serverName] = config + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + sandbox.PipeOut(ctx, stdoutPipe, serverName) + }() + + go func() { + defer wg.Done() + sandbox.PipeOut(ctx, stderrPipe, serverName) + }() + + go func() { + wg.Wait() + err := cmd.Wait() + if err != nil { + log.Errorf(ctx, "Command %s exited with error: %v\n", serverName, err) + } + r.lock.Lock() + delete(r.running, serverName) + r.lock.Unlock() + }() + + return config, nil +} + +func (r *Runner) doStream(ctx context.Context, serverName string, cmd *sandbox.Cmd) (*streamResult, error) { + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to get stdout pipe: %w", err) + } + stdinPipe, err := cmd.StdinPipe() + if err != nil { + return nil, fmt.Errorf("failed to get stdin pipe: %w", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return nil, fmt.Errorf("failed to get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start command: %w", err) + } + + go func() { + sandbox.PipeOut(ctx, stderrPipe, serverName) + if err := cmd.Wait(); err != nil { + log.Errorf(ctx, "Command %s exited with error: %v\n", serverName, err) + } + }() + + return &streamResult{ + cmd: cmd.Cmd, + Stdout: stdoutPipe, + Stdin: stdinPipe, + }, nil +} + +func (r *Runner) Run(ctx context.Context, roots func(ctx context.Context) ([]Root, error), env map[string]string, serverName string, config Server) (Server, error) { + r.lock.Lock() + defer r.lock.Unlock() + + if c, ok := r.running[serverName]; ok { + return c, nil + } + + newConfig, cmd, err := r.newCommand(ctx, env, roots, config) + if err != nil { + return config, err + } + + return r.doRun(ctx, serverName, newConfig, cmd) +} + +func (r *Runner) Stream(ctx context.Context, roots func(context.Context) ([]Root, error), env map[string]string, serverName string, config Server) (*streamResult, error) { + ctx, cancel := context.WithCancel(ctx) + _, cmd, err := r.newCommand(ctx, env, roots, config) + if err != nil { + cancel() + return nil, err + } + result, err := r.doStream(ctx, serverName, cmd) + if err != nil { + cancel() + return nil, fmt.Errorf("failed to run stdio command: %w", err) + } + result.Close = cancel + return result, nil +} diff --git a/pkg/nanobot/mcp/sandbox/out.go b/pkg/nanobot/mcp/sandbox/out.go new file mode 100644 index 00000000..bd2f709f --- /dev/null +++ b/pkg/nanobot/mcp/sandbox/out.go @@ -0,0 +1,21 @@ +package sandbox + +import ( + "bufio" + "context" + "io" + "strings" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" +) + +func PipeOut(ctx context.Context, outRead io.Reader, serverName string) { + scanner := bufio.NewScanner(outRead) + scanner.Buffer(make([]byte, 0, 1024), 10*1024*1024) + for scanner.Scan() { + text := scanner.Text() + if strings.TrimSpace(text) != "" { + log.StderrMessages(ctx, serverName, text) + } + } +} diff --git a/pkg/nanobot/mcp/sandbox/reverseports.go b/pkg/nanobot/mcp/sandbox/reverseports.go new file mode 100644 index 00000000..0cb23837 --- /dev/null +++ b/pkg/nanobot/mcp/sandbox/reverseports.go @@ -0,0 +1,102 @@ +package sandbox + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" + "github.com/gptscript-ai/gptscript/pkg/nanobot/reverseproxy" + "github.com/gptscript-ai/gptscript/pkg/nanobot/supervise" + "github.com/gptscript-ai/gptscript/pkg/nanobot/version" +) + +func startReversePort(ctx context.Context, targetContainerName string, port int, cancel func()) error { + for range 10 { + if err := exec.Command("docker", "start", targetContainerName).Run(); err == nil { + break + } + } + + server, err := reverseproxy.NewTLSServer(port) + if err != nil { + return fmt.Errorf("failed to create reverse proxy server for port %d: %w", port, err) + } + + targetPort, err := server.Start(ctx) + if err != nil { + return fmt.Errorf("failed to start reverse proxy server for port %d: %w", port, err) + } + + ca, err := server.GetCACertPEM() + if err != nil { + return fmt.Errorf("failed to get CA certificate for port %d: %w", port, err) + } + + cert, key, err := server.GenerateClientCert() + if err != nil { + return fmt.Errorf("failed to generate client certificate for port %d: %w", port, err) + } + + containerName := fmt.Sprintf("%s-%d", targetContainerName, port) + cmd := supervise.Cmd(ctx, "docker", "run", "--rm", + "--network", "container:"+targetContainerName, + "--name", containerName, + "-e", "LISTEN_PORT", + "-e", "TARGET_PORT", + "-e", "CA_CERT", + "-e", "CLIENT_CERT", + "-e", "CLIENT_KEY", + version.BaseImage, + "proxy", + ) + cmd.Env = append(os.Environ(), + fmt.Sprintf("LISTEN_PORT=%d", port), + fmt.Sprintf("TARGET_PORT=%d", targetPort), + fmt.Sprintf("CA_CERT=%s", ca), + fmt.Sprintf("CLIENT_CERT=%s", cert), + fmt.Sprintf("CLIENT_KEY=%s", key), + ) + + // just hold open stdin for supervisor + _, err = cmd.StdinPipe() + if err != nil { + return fmt.Errorf("failed to get stdin pipe for reverse proxy container for port %d: %w", port, err) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to get stdout pipe for reverse proxy container for port %d: %w", port, err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to get stderr pipe for reverse proxy container for port %d: %w", port, err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start reverse proxy container for port %d: %w", port, err) + } + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + PipeOut(ctx, stdoutPipe, containerName) + }() + go func() { + defer wg.Done() + PipeOut(ctx, stderrPipe, containerName) + }() + go func() { + defer func() { + cancel() + }() + wg.Wait() + if err := cmd.Wait(); err != nil { + log.Errorf(ctx, "Reverse proxy container for port %d exited with error: %v\n", port, err) + } + }() + return nil +} diff --git a/pkg/nanobot/mcp/sandbox/sandbox.go b/pkg/nanobot/mcp/sandbox/sandbox.go new file mode 100644 index 00000000..39da5f55 --- /dev/null +++ b/pkg/nanobot/mcp/sandbox/sandbox.go @@ -0,0 +1,289 @@ +package sandbox + +import ( + "archive/tar" + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" + "github.com/gptscript-ai/gptscript/pkg/nanobot/supervise" + "github.com/gptscript-ai/gptscript/pkg/nanobot/uuid" + "github.com/gptscript-ai/gptscript/pkg/nanobot/version" +) + +var ( + validChars = regexp.MustCompile(`^[a-zA-Z0-9@:/._-]+$`) + // Must start with git@ or https:// or ssh:// or http:// + gitRepoPrefix = regexp.MustCompile(`^(git@|https://|ssh://|http://)`) +) + +type Command struct { + PublishPorts []string + ReversePorts []int + Roots []Root + Command string + Workdir string + Args []string + Env []string + BaseImage string + Dockerfile string + Source Source +} + +type Root struct { + Name string + Path string +} + +type Source struct { + Repo string + Tag string + Commit string + Branch string + SubPath string + Reference string +} + +type Cmd struct { + *exec.Cmd + cancel func() + postStart func() error +} + +func (c *Cmd) Wait() error { + if c.cancel != nil { + defer c.cancel() + } + return c.Cmd.Wait() +} + +func (c *Cmd) Start() error { + if err := c.Cmd.Start(); err != nil { + return err + } + if c.postStart == nil { + return nil + } + + if err := c.postStart(); err != nil { + c.cancel() + _ = c.Wait() + return fmt.Errorf("post-start hook failed: %w", err) + } + + return nil +} + +func getBaseImage(ctx context.Context, config Command) (string, error) { + baseImage := config.BaseImage + if baseImage == "" { + baseImage = version.BaseImage + } + if config.Dockerfile != "" { + var err error + baseImage, err = buildBaseImage(ctx, config) + if err != nil { + return "", fmt.Errorf("failed to build base image: %w", err) + } + } + if config.Source.Repo != "" { + return buildImage(ctx, baseImage, config) + } + if !validChars.MatchString(baseImage) { + return "", fmt.Errorf("invalid base image: %s", baseImage) + } + return baseImage, nil +} + +func NewCmd(ctx context.Context, sandbox Command) (*Cmd, error) { + baseImage, err := getBaseImage(ctx, sandbox) + if err != nil { + return nil, err + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + return nil, fmt.Errorf("failed to get user cache directory: %w", err) + } + + containerName := fmt.Sprintf("nanobot-%s", strings.Split(uuid.String(), "-")[0]) + dockerArgs := []string{"run", + "-i", "--name", containerName} + + cacheDir = filepath.Join(cacheDir, "nanobot") + for _, dir := range []string{".cache", ".npm", "go/pkg"} { + if err := os.MkdirAll(filepath.Join(cacheDir, dir), 0755); err != nil { + return nil, fmt.Errorf("failed to create cache directory: %w", err) + } + dockerArgs = append(dockerArgs, "-v", fmt.Sprintf("%s/%s:/mcp/%s", cacheDir, dir, dir)) + } + + dockerArgs = append(dockerArgs, "-u", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())) + for _, k := range sandbox.Env { + dockerArgs = append(dockerArgs, "-e", k) + } + + workdir := sandbox.Workdir + for _, root := range sandbox.Roots { + if root.Name == "cwd" && sandbox.Source.Repo == "" && sandbox.Source.SubPath == "" && workdir == "" { + workdir = root.Path + } + dockerArgs = append(dockerArgs, "-v", root.Path+":"+root.Path) + } + if workdir != "" { + dockerArgs = append(dockerArgs, "-w", workdir) + } + for _, port := range sandbox.PublishPorts { + dockerArgs = append(dockerArgs, "-p", "127.0.0.1:"+port+":"+port) + } + dockerArgs = append(dockerArgs, "--", baseImage) + if sandbox.Command != "" { + dockerArgs = append(dockerArgs, sandbox.Command) + } + dockerArgs = append(dockerArgs, sandbox.Args...) + + ctx, cancel := context.WithCancel(ctx) + cmd := supervise.Cmd(ctx, "docker", dockerArgs...) + return &Cmd{ + cancel: cancel, + Cmd: cmd, + postStart: func() error { + for _, port := range sandbox.ReversePorts { + if err := startReversePort(ctx, containerName, port, cancel); err != nil { + return err + } + } + return err + }, + }, nil +} + +func buildImage(ctx context.Context, baseImage string, config Command) (string, error) { + var ( + source = config.Source.Repo + fragment string + isGit = gitRepoPrefix.MatchString(source) + ) + + if !validChars.MatchString(source) { + return "", fmt.Errorf("invalid source repo: %s", source) + } + + if config.Source.Commit != "" { + fragment = config.Source.Commit + } else if config.Source.Tag != "" { + fragment = config.Source.Tag + } else if config.Source.Branch != "" { + fragment = config.Source.Branch + } + if config.Source.SubPath != "" { + fragment += ":" + config.Source.SubPath + } + + if fragment != "" && !validChars.MatchString(fragment) { + return "", fmt.Errorf("invalid source reference: %s", fragment) + } + + if fragment != "" { + source = source + "#" + fragment + } + + uid := os.Getuid() + gid := os.Getgid() + + var cmd *exec.Cmd + if isGit { + log.Infof(ctx, "Downloading source: %s", source) + cmd = exec.CommandContext(ctx, "docker", "build", "-q", "-") + cmd.Stdin = dockerFileToTar(fmt.Sprintf(`FROM %s +USER %d:%d +WORKDIR /mcp +ADD %s /mcp`, baseImage, uid, gid, source)) + } else { + log.Infof(ctx, "Copying source: %s", filepath.Join(config.Source.Repo, config.Source.SubPath)) + srcPath := config.Source.SubPath + if srcPath == "" { + srcPath = "." + } + cmd = exec.CommandContext(ctx, "docker", "build", "-q", "-f", "-", config.Source.Repo) + cmd.Stdin = bytes.NewBufferString(fmt.Sprintf(`FROM %s +USER %d:%d +WORKDIR /mcp +COPY %s /mcp`, baseImage, uid, gid, srcPath)) + } + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to get source %s: %w, output: %s", source, err, string(out)) + } + + id := strings.TrimSpace(string(out)) + log.Infof(ctx, "Image: %s", id) + return id, nil +} + +func dockerFileToTar(dockerfile string) io.Reader { + dockerfile = strings.ReplaceAll(dockerfile, "${NANOBOT_IMAGE}", version.BaseImage) + var buf bytes.Buffer + t := tar.NewWriter(&buf) + if err := t.WriteHeader(&tar.Header{ + Name: "Dockerfile", + Size: int64(len([]byte(dockerfile))), + }); err != nil { + panic(fmt.Errorf("failed to write tar header: %w", err)) + } + if _, err := t.Write([]byte(dockerfile)); err != nil { + panic(fmt.Errorf("failed to write Dockerfile to tar: %w", err)) + } + if err := t.Close(); err != nil { + panic(fmt.Errorf("failed to close tar writer: %w", err)) + } + return &buf +} + +func buildBaseImage(ctx context.Context, config Command) (string, error) { + log.Infof(ctx, "Building base image") + f, err := os.CreateTemp("", "nanobot-dockerfile-*.id") + if err != nil { + return "", fmt.Errorf("failed to create temp file for dockerfile: %w", err) + } + if err := f.Close(); err != nil { + return "", fmt.Errorf("failed to close temp file: %w", err) + } + + defer func() { + _ = os.Remove(f.Name()) + }() + + outBuf := &bytes.Buffer{} + cmd := exec.CommandContext(ctx, "docker", "build", "--iidfile", f.Name(), "-") + cmd.Stdin = dockerFileToTar(config.Dockerfile) + cmd.Stdout = outBuf + stdErr, err := cmd.StderrPipe() + if err != nil { + return "", fmt.Errorf("failed to get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return "", fmt.Errorf("failed to start docker build: %w", err) + } + + lines := bufio.NewScanner(stdErr) + for lines.Scan() { + _, _ = fmt.Fprintln(os.Stderr, lines.Text()) + } + + if err := cmd.Wait(); err != nil { + return "", fmt.Errorf("failed to build base image: %w, output: %s", err, outBuf.String()) + } + + idBytes, err := os.ReadFile(f.Name()) + return strings.TrimSpace(string(idBytes)), err +} diff --git a/pkg/nanobot/mcp/serversession.go b/pkg/nanobot/mcp/serversession.go new file mode 100644 index 00000000..589f17dd --- /dev/null +++ b/pkg/nanobot/mcp/serversession.go @@ -0,0 +1,247 @@ +package mcp + +import ( + "context" + "errors" + "sync" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/uuid" +) + +var ( + _ Wire = (*serverWire)(nil) + _ Wire = (*ServerSession)(nil) +) + +func NewServerSession(ctx context.Context, handler MessageHandler) (*ServerSession, error) { + return NewExistingServerSession(ctx, + SessionState{ + ID: uuid.String(), + }, handler) +} + +func NewExistingServerSession(ctx context.Context, state SessionState, handler MessageHandler) (*ServerSession, error) { + s := &serverWire{ + read: make(chan Message), + noReader: make(chan struct{}), + sessionID: state.ID, + } + s.stopReading() + + session, err := newSession(ctx, s, handler, &state, nil) + if err != nil { + return nil, err + } + for k, v := range state.Attributes { + session.Set(k, v) + } + session.Parent = SessionFromContext(ctx) + return &ServerSession{ + session: session, + wire: s, + }, nil +} + +type ServerSession struct { + session *Session + wire *serverWire +} + +func (s *ServerSession) Wait() { + if s.session == nil { + return + } + s.session.Wait() +} + +func (s *ServerSession) Start(ctx context.Context, handler WireHandler) error { + s.wire.startReading() + + go func() { + defer s.wire.stopReading() + + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-s.wire.read: + if !ok { + return + } + handler(ctx, msg) + } + } + }() + return nil +} + +func (s *ServerSession) SessionID() string { + return s.ID() +} + +func (s *ServerSession) ID() string { + id := s.session.ID() + if id == "" { + return s.wire.SessionID() + } + return id +} + +var ( + ErrNoResponse = errors.New("no response") + ErrNoReader = errors.New("no reader") +) + +func (s *ServerSession) GetSession() *Session { + return s.session +} + +func (s *ServerSession) Exchange(ctx context.Context, msg Message) (Message, error) { + isInit, err := s.session.preInit(&msg) + if err != nil { + return Message{}, err + } + resp, err := s.wire.exchange(ctx, msg) + if err != nil { + return Message{}, err + } + if isInit { + if err := s.session.postInit(&resp); err != nil { + return Message{}, err + } + } + return resp, nil +} + +func (s *ServerSession) Read(ctx context.Context) (Message, bool) { + select { + case msg, ok := <-s.wire.read: + if !ok { + return Message{}, false + } + return msg, true + case <-ctx.Done(): + return Message{}, false + } +} + +func (s *ServerSession) StartReading() { + s.wire.startReading() +} + +func (s *ServerSession) StopReading() { + s.wire.stopReading() +} + +func (s *ServerSession) Send(ctx context.Context, req Message) error { + req.Session = s.session + go s.session.handler.OnMessage(WithSession(ctx, s.session), req) + return nil +} + +func (s *ServerSession) Close(deleteSession bool) { + if s == nil { + return + } + + if s.session == nil { + s.session.Close(deleteSession) + } + if s.wire != nil { + s.wire.Close(deleteSession) + } +} + +type serverWire struct { + ctx context.Context + cancel context.CancelFunc + pending PendingRequests + read chan Message + readerLock sync.RWMutex + noReader chan struct{} + handler WireHandler + sessionID string +} + +func (s *serverWire) SessionID() string { + return s.sessionID +} + +func (s *serverWire) exchange(ctx context.Context, msg Message) (Message, error) { + if msg.ID == nil { + s.handler(ctx, msg) + return Message{}, ErrNoResponse + } + + ch := s.pending.WaitFor(msg.ID) + defer s.pending.Done(msg.ID) + + go func() { + s.handler(ctx, msg) + close(ch) + }() + + select { + case <-ctx.Done(): + return Message{}, ctx.Err() + case <-s.ctx.Done(): + return Message{}, s.ctx.Err() + case m, ok := <-ch: + if !ok { + return Message{}, ErrNoResponse + } + return m, nil + } +} + +func (s *serverWire) Close(bool) { + s.cancel() +} + +func (s *serverWire) Wait() { + <-s.ctx.Done() +} + +func (s *serverWire) Start(ctx context.Context, handler WireHandler) error { + s.ctx, s.cancel = context.WithCancel(ctx) + s.handler = handler + return nil +} + +func (s *serverWire) Send(ctx context.Context, req Message) error { + if s.pending.Notify(req) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.ctx.Done(): + return s.ctx.Err() + case <-s.noReader: + return ErrNoReader + case s.read <- req: + return nil + } +} + +func (s *serverWire) startReading() { + s.readerLock.Lock() + defer s.readerLock.Unlock() + + s.noReader = nil +} + +func (s *serverWire) stopReading() { + s.readerLock.Lock() + defer s.readerLock.Unlock() + + s.noReader = make(chan struct{}) + close(s.noReader) +} + +func (s *serverWire) isReading() bool { + s.readerLock.RLock() + defer s.readerLock.RUnlock() + + return s.noReader != nil +} diff --git a/pkg/nanobot/mcp/servertools.go b/pkg/nanobot/mcp/servertools.go new file mode 100644 index 00000000..f43ce7ab --- /dev/null +++ b/pkg/nanobot/mcp/servertools.go @@ -0,0 +1,211 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + + "github.com/google/jsonschema-go/jsonschema" +) + +type NoResponse *struct{} + +// Invoke is a generic function to handle invoking a handler with a message and payload. R should really be a pointer +// to a type, not the type itself. And a nil response will be treated as a call that does not have a response, like a +// notification handler. In such situation a response signature like (*struct{}, error) is enough. A help type +// NoResponse is just an alias to *struct{} +func Invoke[T any, R comparable](ctx context.Context, msg Message, handler func(ctx context.Context, req Message, payload T) (R, error)) { + var payload T + if len(msg.Params) > 0 && !bytes.Equal(msg.Params, []byte("null")) { + if err := json.Unmarshal(msg.Params, &payload); err != nil { + msg.SendError(ctx, err) + return + } + } + var defR R + r, err := handler(ctx, msg, payload) + if errors.Is(err, ErrNoResponse) { + // no response expected, return + } else if err != nil { + msg.SendError(ctx, err) + } else if _, noResponse := any(r).(NoResponse); noResponse { + // no response expected, return + } else if r != defR { + err := msg.Reply(ctx, r) + if err != nil { + msg.SendError(ctx, err) + } + } +} + +type ServerTools map[string]ServerTool + +func (s ServerTools) Call(ctx context.Context, msg Message, payload CallToolRequest) (*CallToolResult, error) { + tool, ok := s[payload.Name] + if !ok { + return nil, fmt.Errorf("unknown tool %s", payload.Name) + } + + return tool.Invoke(ctx, msg, payload) +} + +type ServerTool interface { + Definition() Tool + Invoke(ctx context.Context, msg Message, call CallToolRequest) (*CallToolResult, error) +} + +type serverTool[In, Out any] struct { + tool Tool + f func(ctx context.Context, in In) (Out, error) +} + +func (s *serverTool[In, Out]) Definition() Tool { + return s.tool +} + +func (s *serverTool[In, Out]) Invoke(ctx context.Context, _ Message, call CallToolRequest) (*CallToolResult, error) { + var in In + if len(call.Arguments) > 0 { + if err := JSONCoerce(call.Arguments, &in); err != nil { + return nil, err + } + } + + out, err := s.f(ctx, in) + if err != nil { + return nil, fmt.Errorf("error invoking tool %s: %w", s.tool.Name, err) + } + + return callResult(out, err) +} + +func NewServerTools(tools ...ServerTool) ServerTools { + if len(tools) == 0 { + return map[string]ServerTool{} + } + + result := make(ServerTools, len(tools)) + for _, tool := range tools { + result[tool.Definition().Name] = tool + } + return result +} + +func NewServerTool[In, Out any](name, description string, handler func(ctx context.Context, in In) (Out, error)) ServerTool { + inSchema, err := jsonschema.For[In](nil) + if err != nil { + panic(err) + } + data, err := json.Marshal(inSchema) + if err != nil { + panic(err) + } + + return &serverTool[In, Out]{ + tool: Tool{ + Name: name, + Description: description, + InputSchema: data, + }, + f: handler, + } +} + +func callResult(object any, err error) (*CallToolResult, error) { + if err != nil { + return nil, err + } + + if _, ok := object.(Content); ok { + // If the object is already a Content, we can return it directly + return &CallToolResult{ + IsError: false, + Content: []Content{object.(Content)}, + }, nil + } + if _, ok := object.(*Content); ok { + // If the object is already a Content, we can return it directly + return &CallToolResult{ + IsError: false, + Content: []Content{*(object.(*Content))}, + }, nil + } + if _, ok := object.([]Content); ok { + // If the object is already a slice of Content, we can return it directly + return &CallToolResult{ + IsError: false, + Content: object.([]Content), + }, nil + } + if _, ok := object.(*CallToolResult); ok { + // If the object is already a CallToolResult, we can return it directly + return object.(*CallToolResult), nil + } + + dataBytes, err := json.Marshal(object) + if err != nil { + return nil, fmt.Errorf("failed to marshal thread data: %w", err) + } + + return &CallToolResult{ + IsError: false, + Content: []Content{ + { + Type: "text", + Text: string(dataBytes), + StructuredContent: object, + }, + }, + }, nil +} + +func (s ServerTools) List(_ context.Context, _ Message, _ ListToolsRequest) (*ListToolsResult, error) { + // purposefully not set to nil, so that we can return an empty list + tools := []Tool{} + for _, key := range slices.Sorted(maps.Keys(s)) { + tools = append(tools, s[key].Definition()) + } + + return &ListToolsResult{ + Tools: tools, + }, nil +} + +func JSONCoerce[T any](in any, out *T) error { + switch s := any(out).(type) { + case *string: + if inStr, ok := in.(string); ok { + *s = inStr + return nil + } + data, err := json.Marshal(in) + if err != nil { + return err + } + *s = string(data) + return nil + } + + if v, ok := in.(T); ok { + *out = v + return nil + } + + var data []byte + if inBytes, ok := in.([]byte); ok { + data = inBytes + } else if inStr, ok := in.(string); ok { + data = []byte(inStr) + } else { + var err error + data, err = json.Marshal(in) + if err != nil { + return err + } + } + return json.Unmarshal(data, out) +} diff --git a/pkg/nanobot/mcp/session.go b/pkg/nanobot/mcp/session.go new file mode 100644 index 00000000..0115a6b4 --- /dev/null +++ b/pkg/nanobot/mcp/session.go @@ -0,0 +1,521 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "reflect" + "sync" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/complete" + "github.com/gptscript-ai/gptscript/pkg/nanobot/uuid" +) + +var ErrNoResult = errors.New("no result in response") + +type MessageHandler interface { + OnMessage(ctx context.Context, msg Message) +} + +type MessageHandlerFunc func(ctx context.Context, msg Message) + +func (f MessageHandlerFunc) OnMessage(ctx context.Context, msg Message) { + f(ctx, msg) +} + +type Wire interface { + Close(deleteSession bool) + Wait() + Start(ctx context.Context, handler WireHandler) error + Send(ctx context.Context, req Message) error + SessionID() string +} + +type WireHandler func(ctx context.Context, msg Message) + +var sessionKey = struct{}{} + +func SessionFromContext(ctx context.Context) *Session { + if ctx == nil { + return nil + } + s, ok := ctx.Value(sessionKey).(*Session) + if !ok { + return nil + } + return s +} + +func WithSession(ctx context.Context, s *Session) context.Context { + if s == nil { + return ctx + } + return context.WithValue(ctx, sessionKey, s) +} + +type Session struct { + ctx context.Context + cancel context.CancelFunc + wire Wire + handler MessageHandler + pendingRequest PendingRequests + InitializeResult InitializeResult + InitializeRequest InitializeRequest + recorder *recorder + Parent *Session + attributes map[string]any + lock sync.Mutex +} + +const SessionEnvMapKey = "env" + +func (s *Session) Context() context.Context { + return s.ctx +} + +func (s *Session) ID() string { + if s == nil || s.wire == nil { + return "" + } + return s.wire.SessionID() +} + +func (s *Session) State() (*SessionState, error) { + if s == nil { + return nil, nil + } + + attr := make(map[string]any, len(s.attributes)) + for k, v := range s.attributes { + if serializable, ok := v.(Serializable); ok { + data, err := serializable.Serialize() + if err != nil { + return nil, fmt.Errorf("failed to serialize attribute %s: %w", k, err) + } + if data != nil { + attr[k] = data + } + } + } + + return &SessionState{ + ID: s.wire.SessionID(), + InitializeResult: s.InitializeResult, + InitializeRequest: s.InitializeRequest, + Attributes: attr, + }, nil +} + +func (s *Session) EnvMap() map[string]string { + if s == nil { + return map[string]string{} + } + + s.lock.Lock() + defer s.lock.Unlock() + + if s.attributes == nil { + s.attributes = make(map[string]any) + } + + env, ok := s.attributes[SessionEnvMapKey].(map[string]string) + if !ok { + env = make(map[string]string) + s.attributes[SessionEnvMapKey] = env + } + + if s.Parent != nil { + parentEnv := s.Parent.EnvMap() + for k, v := range parentEnv { + if _, exists := env[k]; !exists { + env[k] = v + } + } + } + + return env +} + +func (s *Session) Delete(key string) { + if s == nil { + return + } + if s.Parent != nil { + defer s.Parent.Delete(key) + } + s.lock.Lock() + defer s.lock.Unlock() + delete(s.attributes, key) +} + +func (s *Session) Set(key string, value any) { + if s == nil { + return + } + s.lock.Lock() + defer s.lock.Unlock() + if s.attributes == nil { + s.attributes = make(map[string]any) + } + s.attributes[key] = value +} + +func (s *Session) copyInto(out, in any) bool { + dstVal := reflect.ValueOf(out) + srcVal := reflect.ValueOf(in) + if srcVal.Type().AssignableTo(dstVal.Type()) { + reflect.Indirect(dstVal).Set(reflect.Indirect(srcVal)) + return true + } + + if dstVal.Type().Kind() == reflect.Ptr && srcVal.Type().AssignableTo(dstVal.Type().Elem()) { + dstVal.Elem().Set(srcVal) + return true + } + + switch v := in.(type) { + case float64: + if outNum, ok := out.(*float64); ok { + *outNum = v + return true + } + case SavedString: + if outStr, ok := out.(*string); ok { + *outStr = string(v) + return true + } + case string: + if outStr, ok := out.(*string); ok { + *outStr = v + return true + } + } + + return false +} + +func (s *Session) Get(key string, out any) (ret bool) { + if s == nil { + return false + } + defer func() { + if !ret && s != nil && s.Parent != nil { + ret = s.Parent.Get(key, out) + } + }() + + s.lock.Lock() + v, ok := s.attributes[key] + if !ok { + s.lock.Unlock() + return false + } + s.lock.Unlock() + + if v == nil { + return false + } + + if out == nil { + return true + } + + if s.copyInto(out, v) { + return true + } + + if deserializable, ok := out.(Deserializable); ok { + newOut, err := deserializable.Deserialize(v) + if err != nil { + s.lock.Lock() + delete(s.attributes, key) + s.lock.Unlock() + return false + } + s.lock.Lock() + s.attributes[key] = newOut + s.lock.Unlock() + return true + } + + panic(fmt.Sprintf("can not marshal %T to type: %T", v, out)) +} + +func (s *Session) Attributes() map[string]any { + if s == nil || len(s.attributes) == 0 { + return nil + } + + s.lock.Lock() + defer s.lock.Unlock() + + return maps.Clone(s.attributes) +} + +func (s *Session) Close(deleteSession bool) { + if s.wire != nil { + s.wire.Close(deleteSession) + } + s.pendingRequest.Close() + s.cancel() +} + +func (s *Session) Wait() { + if s.wire == nil { + <-s.ctx.Done() + return + } + s.wire.Wait() +} + +func (s *Session) normalizeProgress(progress *NotificationProgressRequest) { + var ( + progressKey = fmt.Sprintf("progress-token:%v", progress.ProgressToken) + lastProgress, newProgress float64 + ) + + if ok := s.Get(progressKey, &lastProgress); !ok { + lastProgress = 0 + } + + if progress.Progress != "" { + newF, err := progress.Progress.Float64() + if err == nil { + newProgress = newF + } + } + + if newProgress <= lastProgress { + if progress.Total == nil { + newProgress = lastProgress + 1 + } else { + // If total is set then something is probably trying to make the progress pretty + // so we don't want to just increment by 1 and mess that up. + newProgress = lastProgress + 0.01 + } + } + data, err := json.Marshal(newProgress) + if err == nil { + progress.Progress = json.Number(data) + } + s.Set(progressKey, newProgress) +} + +func (s *Session) SendPayload(ctx context.Context, method string, payload any) error { + if progress, ok := payload.(NotificationProgressRequest); ok { + s.normalizeProgress(&progress) + payload = progress + } + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + return s.Send(ctx, Message{ + Method: method, + Params: data, + }) +} + +func (s *Session) Send(ctx context.Context, req Message) error { + if s.wire == nil { + return fmt.Errorf("empty session: wire is not initialized") + } + + req.JSONRPC = "2.0" + s.recorder.save(ctx, s.wire.SessionID(), true, req) + return s.wire.Send(ctx, req) +} + +type ExchangeOption struct { + ProgressToken any +} + +func (e ExchangeOption) Merge(other ExchangeOption) (result ExchangeOption) { + result.ProgressToken = complete.Last(e.ProgressToken, other.ProgressToken) + return +} + +func (s *Session) preInit(msg *Message) (bool, error) { + if msg.Method == "initialize" { + var init InitializeRequest + if err := json.Unmarshal(msg.Params, &init); err != nil { + return false, fmt.Errorf("failed to unmarshal initialize request: %w", err) + } + s.InitializeRequest = init + return true, nil + } + + return false, nil +} + +func (s *Session) postInit(msg *Message) error { + if len(msg.Result) == 0 { + return nil + } + var init InitializeResult + if err := json.Unmarshal(msg.Result, &init); err != nil { + return fmt.Errorf("failed to unmarshal initialize result: %w", err) + } + s.InitializeResult = init + return nil +} + +func (s *Session) marshalResponse(m Message, out any) error { + if mOut, ok := out.(*Message); ok { + *mOut = m + return nil + } + if m.Error != nil { + return fmt.Errorf("error from server: %s", m.Error.Message) + } + if m.Result == nil { + return ErrNoResult + } + if err := json.Unmarshal(m.Result, out); err != nil { + return fmt.Errorf("failed to unmarshal result: %w", err) + } + return nil +} + +func (s *Session) toRequest(method string, in any, opt ExchangeOption) (*Message, error) { + req, ok := in.(*Message) + if !ok { + data, err := json.Marshal(in) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + req = &Message{ + Method: method, + Params: data, + } + } + + if req.ID == nil || req.ID == "" { + req.ID = uuid.String() + } + if opt.ProgressToken != nil { + if err := req.SetProgressToken(opt.ProgressToken); err != nil { + return nil, fmt.Errorf("failed to set progress token: %w", err) + } + } + + return req, nil +} + +func (s *Session) Exchange(ctx context.Context, method string, in, out any, opts ...ExchangeOption) error { + opt := complete.Complete(opts...) + req, err := s.toRequest(method, in, opt) + if err != nil { + return err + } + + if req.ID == nil { + return s.Send(ctx, *req) + } + + ch := s.pendingRequest.WaitFor(req.ID) + defer s.pendingRequest.Done(req.ID) + + isInit, err := s.preInit(req) + if err != nil { + return err + } + + errChan := make(chan error, 1) + + go func() { + defer close(errChan) + + if err := s.Send(ctx, *req); err != nil { + errChan <- fmt.Errorf("failed to send request: %w", err) + } + }() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err = <-errChan: + if err != nil { + return err + } + // If the error is nil, then the send call was successful. + // Set the error channel to nil so that this case always blocks. + errChan = nil + case m := <-ch: + if isInit { + if err := s.postInit(&m); err != nil { + return fmt.Errorf("failed to post init: %w", err) + } + } + return s.marshalResponse(m, out) + } + } +} + +func (s *Session) onWire(ctx context.Context, message Message) { + s.recorder.save(s.ctx, s.wire.SessionID(), false, message) + message.Session = s + if s.pendingRequest.Notify(message) { + return + } + s.handler.OnMessage(WithSession(ctx, s), message) +} + +func NewEmptySession(ctx context.Context) *Session { + s := &Session{} + s.ctx, s.cancel = context.WithCancel(WithSession(ctx, s)) + return s +} + +func newSession(ctx context.Context, wire Wire, handler MessageHandler, session *SessionState, r *recorder) (*Session, error) { + s := &Session{ + wire: wire, + handler: handler, + recorder: r, + } + if session != nil { + s.InitializeRequest = session.InitializeRequest + s.InitializeResult = session.InitializeResult + } + withSession := WithSession(ctx, s) + s.ctx, s.cancel = context.WithCancel(withSession) + + if err := wire.Start(s.ctx, s.onWire); err != nil { + return nil, err + } + + go func() { + wire.Wait() + s.Close(false) + }() + + return s, nil +} + +type recorder struct { +} + +func (r *recorder) save(ctx context.Context, sessionID string, send bool, msg Message) { +} + +type Serializable interface { + Serialize() (any, error) +} + +type Deserializable interface { + Deserialize(v any) (any, error) +} + +type Closer interface { + Close() error +} + +type SavedString string + +func (s SavedString) Serialize() (any, error) { + return s, nil +} diff --git a/pkg/nanobot/mcp/sessionstore.go b/pkg/nanobot/mcp/sessionstore.go new file mode 100644 index 00000000..7f2c71d0 --- /dev/null +++ b/pkg/nanobot/mcp/sessionstore.go @@ -0,0 +1,44 @@ +package mcp + +import ( + "net/http" + "sync" +) + +type SessionStore interface { + ExtractID(*http.Request) string + Store(*http.Request, string, *ServerSession) error + Load(*http.Request, string) (*ServerSession, bool, error) + LoadAndDelete(*http.Request, string) (*ServerSession, bool, error) +} + +type inMemory struct { + sessions sync.Map +} + +func NewInMemorySessionStore() SessionStore { + return &inMemory{} +} + +func (s *inMemory) ExtractID(req *http.Request) string { + return req.Header.Get("Mcp-Session-Id") +} + +func (s *inMemory) Store(_ *http.Request, sessionID string, session *ServerSession) error { + s.sessions.Store(sessionID, session) + return nil +} + +func (s *inMemory) Load(_ *http.Request, sessionID string) (*ServerSession, bool, error) { + if v, ok := s.sessions.Load(sessionID); ok { + return v.(*ServerSession), true, nil + } + return nil, false, nil +} + +func (s *inMemory) LoadAndDelete(_ *http.Request, sessionID string) (*ServerSession, bool, error) { + if v, ok := s.sessions.LoadAndDelete(sessionID); ok { + return v.(*ServerSession), true, nil + } + return nil, false, nil +} diff --git a/pkg/nanobot/mcp/stdio.go b/pkg/nanobot/mcp/stdio.go new file mode 100644 index 00000000..56ad39a2 --- /dev/null +++ b/pkg/nanobot/mcp/stdio.go @@ -0,0 +1,136 @@ +package mcp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + log2 "log" + "os/exec" + "strings" + "sync" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" +) + +type waiter struct { + running chan struct{} + closed bool + lock sync.Mutex +} + +func newWaiter() *waiter { + return &waiter{ + running: make(chan struct{}), + } +} + +func (w *waiter) Wait() { + <-w.running +} + +func (w *waiter) Close() { + w.lock.Lock() + if !w.closed { + w.closed = true + close(w.running) + } + w.lock.Unlock() +} + +type Stdio struct { + stdout io.Reader + stdin io.Writer + cmd *exec.Cmd + closer func() + server string + pendingRequest PendingRequests + waiter *waiter + writeLock sync.Mutex +} + +func (s *Stdio) Send(ctx context.Context, req Message) error { + s.writeLock.Lock() + defer s.writeLock.Unlock() + + data, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + if s.cmd != nil && (s.cmd.Process == nil || s.cmd.ProcessState != nil) { + return fmt.Errorf("stdin is closed") + } + + log.Messages(ctx, s.server, true, data) + _, err = s.stdin.Write(append(data, '\n')) + return err +} + +func (s *Stdio) SessionID() string { + // Stdio does not have a session ID, return an empty string + return "" +} + +func (s *Stdio) Wait() { + s.waiter.Wait() +} + +func (s *Stdio) Close(bool) { + s.closer() + s.waiter.Close() +} + +func (s *Stdio) Start(ctx context.Context, handler WireHandler) error { + context.AfterFunc(ctx, func() { + s.Close(false) + }) + go func() { + defer s.Close(false) + err := s.start(ctx, handler) + if err != nil { + log2.Fatal(err) + } + }() + return nil +} + +func (s *Stdio) start(ctx context.Context, handler WireHandler) error { + defer s.Close(false) + + buf := bufio.NewScanner(s.stdout) + buf.Buffer(make([]byte, 0, 1024), 10*1024*1024) + for buf.Scan() { + text := strings.TrimSpace(buf.Text()) + log.Messages(ctx, s.server, false, []byte(text)) + var msg Message + if err := json.Unmarshal([]byte(text), &msg); err != nil { + log.Errorf(ctx, "failed to unmarshal message: %v", err) + continue + } + go handler(ctx, msg) + } + return buf.Err() +} + +func newStdioClient(ctx context.Context, roots func(context.Context) ([]Root, error), env map[string]string, serverName string, config Server, r *Runner) (*Stdio, error) { + result, err := r.Stream(ctx, roots, env, serverName, config) + if err != nil { + return nil, fmt.Errorf("failed to create stream: %w", err) + } + + s := NewStdio(serverName, result.cmd, result.Stdout, result.Stdin, result.Close) + return s, nil +} + +func NewStdio(server string, cmd *exec.Cmd, in io.Reader, out io.Writer, close func()) *Stdio { + return &Stdio{ + server: server, + cmd: cmd, + stdout: in, + stdin: out, + closer: close, + waiter: newWaiter(), + } +} diff --git a/pkg/nanobot/mcp/stdioserver.go b/pkg/nanobot/mcp/stdioserver.go new file mode 100644 index 00000000..de2035eb --- /dev/null +++ b/pkg/nanobot/mcp/stdioserver.go @@ -0,0 +1,62 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" +) + +type StdioServer struct { + MessageHandler MessageHandler + stdio *Stdio + env map[string]string +} + +func NewStdioServer(env map[string]string, handler MessageHandler) *StdioServer { + return &StdioServer{ + env: env, + MessageHandler: handler, + } +} + +func (s *StdioServer) Wait() { + if s.stdio != nil { + s.stdio.Wait() + } +} + +func (s *StdioServer) Start(ctx context.Context, in io.ReadCloser, out io.WriteCloser) error { + session, err := NewServerSession(ctx, s.MessageHandler) + if err != nil { + return fmt.Errorf("failed to create stdio session: %w", err) + } + + maps.Copy(session.session.EnvMap(), s.env) + + s.stdio = NewStdio("proxy", nil, in, out, func() {}) + + if err = s.stdio.Start(ctx, func(ctx context.Context, msg Message) { + resp, err := session.Exchange(ctx, msg) + if errors.Is(err, ErrNoResponse) { + return + } else if err != nil { + log.Errorf(ctx, "failed to exchange message: %v", err) + } + if err := s.stdio.Send(ctx, resp); err != nil { + log.Errorf(ctx, "failed to send message in reply to %v: %v", msg.ID, err) + } + }); err != nil { + return fmt.Errorf("failed to start stdio: %w", err) + } + + go func() { + s.stdio.Wait() + session.Close(false) + }() + + return nil +} diff --git a/pkg/nanobot/mcp/tokenstorage.go b/pkg/nanobot/mcp/tokenstorage.go new file mode 100644 index 00000000..e27318ec --- /dev/null +++ b/pkg/nanobot/mcp/tokenstorage.go @@ -0,0 +1,88 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/adrg/xdg" + "golang.org/x/oauth2" +) + +const localTokenFileName = "tokens.json" + +type TokenStorage interface { + GetTokenConfig(context.Context, string) (*oauth2.Config, *oauth2.Token, error) + SetTokenConfig(context.Context, string, *oauth2.Config, *oauth2.Token) error +} + +func NewDefaultLocalStorage() TokenStorage { + return NewLocalTokenStorage(xdg.DataHome + "/nanobot") +} + +func NewLocalTokenStorage(dir string) TokenStorage { + return &localTokenStorage{ + dir: dir, + } +} + +type localTokenStorage struct { + dir string +} + +type localData struct { + Config *oauth2.Config `json:"config,omitempty"` + Token *oauth2.Token `json:"token,omitempty"` +} + +func (l *localTokenStorage) GetTokenConfig(_ context.Context, url string) (*oauth2.Config, *oauth2.Token, error) { + m, err := l.readFile() + if err != nil { + return nil, nil, err + } + + d := m[url] + return d.Config, d.Token, nil +} + +func (l *localTokenStorage) SetTokenConfig(_ context.Context, url string, config *oauth2.Config, token *oauth2.Token) error { + m, err := l.readFile() + if err != nil { + return err + } + + m[url] = localData{ + Config: config, + Token: token, + } + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token data: %w", err) + } + + if err = os.MkdirAll(l.dir, 0700); err != nil { + return fmt.Errorf("failed to create token directory: %w", err) + } + + if err = os.WriteFile(filepath.Join(l.dir, localTokenFileName), data, 0600); err != nil { + return fmt.Errorf("failed to write token file: %w", err) + } + + return nil +} + +func (l *localTokenStorage) readFile() (map[string]localData, error) { + data, err := os.ReadFile(filepath.Join(l.dir, localTokenFileName)) + if errors.Is(err, os.ErrNotExist) { + return make(map[string]localData), nil + } else if err != nil { + return nil, fmt.Errorf("failed to read token file: %w", err) + } + + var m map[string]localData + return m, json.Unmarshal(data, &m) +} diff --git a/pkg/nanobot/mcp/types.go b/pkg/nanobot/mcp/types.go new file mode 100644 index 00000000..ac1ffaad --- /dev/null +++ b/pkg/nanobot/mcp/types.go @@ -0,0 +1,339 @@ +package mcp + +import ( + "encoding/json" +) + +type ClientCapabilities struct { + Roots *RootsCapability `json:"roots,omitempty"` + Sampling *struct{} `json:"sampling,omitzero"` + Elicitation *struct{} `json:"elicitation,omitzero"` +} + +type RootsCapability struct { + ListChanged bool `json:"listChanged"` +} + +type ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type ServerCapabilities struct { + Experimental map[string]any `json:"experimental,omitempty"` + Logging *struct{} `json:"logging,omitempty"` + Prompts *PromptsServerCapability `json:"prompts,omitempty"` + Resources *ResourcesServerCapability `json:"resources,omitempty"` + Tools *ToolsServerCapability `json:"tools,omitempty"` +} + +type ToolsServerCapability struct { + ListChanged bool `json:"listChanged"` +} + +type PromptsServerCapability struct { + ListChanged bool `json:"listChanged"` +} + +type ResourcesServerCapability struct { + Subscribe bool `json:"subscribe"` + ListChanged bool `json:"listChanged"` +} + +type ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +type InitializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities ServerCapabilities `json:"capabilities"` + ServerInfo ServerInfo `json:"serverInfo"` + Instructions string `json:"instructions"` +} + +type InitializeRequest struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities ClientCapabilities `json:"capabilities"` + ClientInfo ClientInfo `json:"clientInfo"` +} + +type PingRequest struct { +} + +type PingResult struct { +} + +type ElicitResult struct { + // Action must be one of "accept", "decline", "cancel" + Action string `json:"action"` + Content map[string]any `json:"content,omitempty"` +} + +type ElicitRequest struct { + Message string `json:"message,omitempty"` + RequestedSchema PrimitiveSchema `json:"requestedSchema,omitzero"` + Meta json.RawMessage `json:"_meta,omitzero"` +} + +type PrimitiveSchema struct { + // Type must be "object" only + Type string `json:"type"` + Properties map[string]PrimitiveProperty `json:"properties"` +} + +type PrimitiveProperty struct { + // Type must be one of "string", "number", "boolean", "enum", "integer" + Type string `json:"type"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Minimum *json.Number `json:"minimum,omitempty"` + Maximum *json.Number `json:"maximum,omitempty"` + Default *bool `json:"default,omitempty"` + Enum []string `json:"enum,omitempty"` + EnumNames []string `json:"enumNames,omitempty"` + // Format must be one of "date-time", "email", "uri", "date" + Format string `json:"format,omitempty"` +} + +type ModelPreferences struct { + Hints []ModelHint `json:"hints,omitzero"` + CostPriority *float64 `json:"costPriority"` + SpeedPriority *float64 `json:"speedPriority"` + IntelligencePriority *float64 `json:"intelligencePriority"` +} + +type ModelHint struct { + Name string `json:"name"` +} +type CreateMessageRequest struct { + Messages []SamplingMessage `json:"messages,omitzero"` + ModelPreferences ModelPreferences `json:"modelPreferences,omitzero"` + SystemPrompt string `json:"systemPrompt,omitzero"` + IncludeContext string `json:"includeContext,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` + Temperature *json.Number `json:"temperature,omitempty"` + StopSequences []string `json:"stopSequences,omitzero"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ListRootsRequest struct { +} + +type ListRootsResult struct { + Roots []Root `json:"roots"` +} + +type Root struct { + URI string `json:"uri"` + Name string `json:"name,omitempty"` +} + +type LoggingMessage struct { + Level string `json:"level"` + Logger string `json:"logger,omitempty"` + Data any `json:"data"` +} + +type SamplingMessage struct { + Role string `json:"role,omitempty"` + Content Content `json:"content,omitempty"` +} + +type Content struct { + Type string `json:"type,omitempty"` + + // Text is set when type is "text" + Text string `json:"text,omitempty"` + + // StructuredContent is set when the content is structured. The spec isn't clear when, but it's + // likely to only be set when type is "text". + StructuredContent any `json:"structuredContent,omitempty"` + + // Data is set when type is "image" or "audio" + Data string `json:"data,omitempty"` + // MIMEType is set when type is "image" or "audio" + MIMEType string `json:"mimeType,omitempty"` + + // Resource is set when type is "resource" + Resource *EmbeddedResource `json:"resource,omitempty"` +} + +func (c Content) MarshalJSON() ([]byte, error) { + type Alias Content + if c.Type == "" { + if c.Resource != nil { + c.Type = "resource" + } else if c.Text != "" || c.StructuredContent != nil { + c.Type = "text" + } else if c.Data != "" { + c.Type = "image" + } + } + return json.Marshal((*Alias)(&c)) +} + +type CreateMessageResult struct { + Content Content `json:"content,omitempty"` + Role string `json:"role,omitempty"` + Model string `json:"model,omitempty"` + StopReason string `json:"stopReason,omitempty"` +} + +func (c *Content) ToImageURL() string { + return "data:" + c.MIMEType + ";base64," + c.Data +} + +type EmbeddedResource struct { + URI string `json:"uri,omitempty"` + MIMEType string `json:"mimeType,omitempty"` + Text string `json:"text,omitempty"` + Blob string `json:"blob,omitempty"` +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema json.RawMessage `json:"inputSchema,omitzero"` + OutputSchema json.RawMessage `json:"outputSchema,omitzero"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` +} + +type ToolAnnotations struct { + Title string `json:"title,omitempty"` + ReadOnlyHint bool `json:"readOnlyHint,omitempty"` + DestructiveHint *bool `json:"destructiveHint,omitempty"` + IdempotentHint bool `json:"idempotentHint,omitempty"` + OpenWorldHint *bool `json:"openWorldHint,omitempty"` +} + +func (t ToolAnnotations) IsOpenWorld() bool { + if t.OpenWorldHint == nil { + return true + } + return *t.OpenWorldHint +} + +func (t ToolAnnotations) IsDestructive() bool { + if t.DestructiveHint == nil { + return true + } + return *t.DestructiveHint +} + +type CallToolResult struct { + IsError bool `json:"isError"` + Content []Content `json:"content,omitzero"` +} + +type CallToolRequest struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments,omitempty"` +} + +var EmptyObjectSchema = json.RawMessage(`{"type": "object", "properties": {}, "additionalProperties": false, "required": []}`) + +type ListToolsRequest struct { +} + +type ListToolsResult struct { + Tools []Tool `json:"tools"` +} + +type GetPromptRequest struct { + Name string `json:"name"` + Arguments map[string]string `json:"arguments,omitempty"` +} + +type GetPromptResult struct { + Description string `json:"description,omitempty"` + Messages []PromptMessage `json:"messages"` +} + +type PromptMessage struct { + Role string `json:"role"` + Content Content `json:"content"` +} + +type ReadResourceRequest struct { + URI string `json:"uri"` +} + +type ReadResourceResult struct { + Contents []ResourceContent `json:"contents"` +} + +type ResourceContent struct { + URI string `json:"uri"` + MimeType string `json:"mimeType"` + Text string `json:"text,omitempty"` + Blob string `json:"blob,omitempty"` +} + +type ListResourceTemplatesRequest struct { +} + +type ListResourceTemplatesResult struct { + ResourceTemplates []ResourceTemplate `json:"resourceTemplates"` +} + +type ResourceTemplate struct { + URITemplate string `json:"uriTemplate"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + MimeType string `json:"mimeType,omitempty"` + Annotations *Annotations `json:"annotations,omitempty"` +} + +type ListResourcesRequest struct { +} + +type ListResourcesResult struct { + Resources []Resource `json:"resources"` +} + +type Resource struct { + URI string `json:"uri"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + MimeType string `json:"mimeType,omitempty"` + Annotations *Annotations `json:"annotations,omitempty"` + Size int64 `json:"size,omitempty"` +} + +type Annotations struct { + Audience []string `json:"audience,omitempty"` + Priority json.Number `json:"priority,omitempty"` +} + +type ListPromptsRequest struct { +} + +type ListPromptsResult struct { + Prompts []Prompt `json:"prompts"` +} + +type Prompt struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Arguments []PromptArgument `json:"arguments,omitempty"` +} + +type PromptArgument struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` +} + +type Notification struct { +} + +type NotificationProgressRequest struct { + ProgressToken any `json:"progressToken"` + Progress json.Number `json:"progress"` + Total *json.Number `json:"total,omitempty"` + Message string `json:"message,omitempty"` + Meta map[string]any `json:"_meta,omitzero"` +} diff --git a/pkg/nanobot/printer/lineprefix.go b/pkg/nanobot/printer/lineprefix.go new file mode 100644 index 00000000..02b34e10 --- /dev/null +++ b/pkg/nanobot/printer/lineprefix.go @@ -0,0 +1,142 @@ +package printer + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "sync" + + "golang.org/x/term" +) + +var ( + prettyPrint = os.Getenv("NANOBOT_LOG_PRETTY_PRINT") != "" + noColors = os.Getenv("NANOBOT_NO_COLORS") != "" || os.Getenv("NO_COLOR") != "" + printLock sync.Mutex + lastPrefix string + currentLine string + longestPrefix int + + termColorToAscii = map[string]string{ + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + } + lightVariants = map[string]string{ + "red": "\033[91m", + "green": "\033[92m", + "yellow": "\033[93m", + "blue": "\033[94m", + "magenta": "\033[95m", + } + colors = []string{ + "green", "yellow", "blue", "magenta", "red", + } + lastColorIndex = 0 + prefixToColor = map[string]string{} +) + +func appendToLine(prefix, content string) { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err == nil { + if remaining := width - len(currentLine); len(content) > remaining { + appendToLine(prefix, content[:remaining]) + newline() + printPrefix(prefix, content[remaining:]) + return + } + } + + _, _ = fmt.Fprint(os.Stderr, content) + currentLine += content +} + +func newline() { + _, _ = fmt.Fprint(os.Stderr, "\n") + currentLine = "" + lastPrefix = "" +} + +func printPrefix(prefix, content string) { + if lastPrefix == "" { + appendToLine(prefix, prefix+" "+content) + } else if lastPrefix == prefix { + appendToLine(prefix, content) + } else if lastPrefix != prefix { + newline() + appendToLine(prefix, prefix+" "+content) + } + lastPrefix = prefix +} + +func formatPrefix(prefix string) string { + if len(prefix) < 3 || noColors { + return prefix + } + + key := strings.ReplaceAll(prefix[2:], " ", "") + + colorsCodes := termColorToAscii + if strings.HasPrefix(prefix, "-") { + colorsCodes = lightVariants + } + + if color, ok := prefixToColor[key]; ok { + return color + prefix + "\033[0m" + } + + if lastColorIndex >= len(colors) { + lastColorIndex = 0 + } + + color := colorsCodes[colors[lastColorIndex]] + prefixToColor[key] = color + lastColorIndex++ + + return color + prefix + "\033[0m" +} + +func Prefix(prefix, content string) { + if content == "" { + return + } + + if prettyPrint { + jsonData := map[string]any{} + if err := json.Unmarshal([]byte(content), &jsonData); err == nil { + if jsonFormatted, err := json.MarshalIndent(jsonData, "", " "); err == nil { + content = string(jsonFormatted) + } + } + } + + printLock.Lock() + defer printLock.Unlock() + + if len(prefix) > longestPrefix { + longestPrefix = len(prefix) + } + + // pad prefix to longestPrefix + if len(prefix) < longestPrefix { + prefix += strings.Repeat(" ", longestPrefix-len(prefix)) + } + + prefix = formatPrefix(prefix + "│") + + lines := strings.Split(content, "\n") + for i, line := range lines { + if i > 0 { + newline() + + if i == len(lines)-1 && line == "" { + continue // Skip empty lines at the end + } + } + + printPrefix(prefix, line) + } +} diff --git a/pkg/nanobot/reverseproxy/server.go b/pkg/nanobot/reverseproxy/server.go new file mode 100644 index 00000000..87676670 --- /dev/null +++ b/pkg/nanobot/reverseproxy/server.go @@ -0,0 +1,246 @@ +package reverseproxy + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net" + "time" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" +) + +// TLSServer represents a TLS server with mTLS authentication +type TLSServer struct { + caCert *x509.Certificate + caKey *ecdsa.PrivateKey + serverCert *x509.Certificate + serverKey *ecdsa.PrivateKey + config *tls.Config + targetPort int +} + +// NewTLSServer creates a new TLS server with generated certificates +func NewTLSServer(targetPort int) (*TLSServer, error) { + // Generate CA key and certificate + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate CA key: %v", err) + } + + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Nanobot Temp CA"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + caCertDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + return nil, fmt.Errorf("failed to create CA certificate: %v", err) + } + + caCert, err := x509.ParseCertificate(caCertDER) + if err != nil { + return nil, fmt.Errorf("failed to parse CA certificate: %v", err) + } + + // Generate server key and certificate + serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate server key: %v", err) + } + + serverTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{ + Organization: []string{"Test Server"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + + serverCertDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, caCert, &serverKey.PublicKey, caKey) + if err != nil { + return nil, fmt.Errorf("failed to create server certificate: %v", err) + } + + serverCert, err := x509.ParseCertificate(serverCertDER) + if err != nil { + return nil, fmt.Errorf("failed to parse server certificate: %v", err) + } + + // Create TLS config + config := &tls.Config{ + Certificates: []tls.Certificate{ + { + Certificate: [][]byte{serverCertDER}, + PrivateKey: serverKey, + Leaf: serverCert, + }, + }, + ClientCAs: x509.NewCertPool(), + ClientAuth: tls.RequireAndVerifyClientCert, + MinVersion: tls.VersionTLS12, + } + + config.ClientCAs.AddCert(caCert) + + return &TLSServer{ + caCert: caCert, + caKey: caKey, + serverCert: serverCert, + serverKey: serverKey, + config: config, + targetPort: targetPort, + }, nil +} + +// GetCACertPEM returns the CA certificate in PEM format +func (s *TLSServer) GetCACertPEM() ([]byte, error) { + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: s.caCert.Raw, + }), nil +} + +// GenerateClientCert generates a client certificate signed by the CA +func (s *TLSServer) GenerateClientCert() ([]byte, []byte, error) { + clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate client key: %v", err) + } + + clientTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{ + Organization: []string{"Nanobot Client"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + clientCertDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, s.caCert, &clientKey.PublicKey, s.caKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to create client certificate: %v", err) + } + + clientCertPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: clientCertDER, + }) + + clientKeyBytes, err := x509.MarshalECPrivateKey(clientKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal client key: %v", err) + } + + clientKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: clientKeyBytes, + }) + + return clientCertPEM, clientKeyPEM, nil +} + +// Start starts the TLS server on a random port and returns the allocated port +func (s *TLSServer) Start(ctx context.Context) (int, error) { + listener, err := tls.Listen("tcp4", ":0", s.config) + if err != nil { + return 0, fmt.Errorf("failed to create listener: %v", err) + } + + context.AfterFunc(ctx, func() { + _ = listener.Close() + }) + + // Get the actual port that was allocated + addr := listener.Addr().(*net.TCPAddr) + port := addr.Port + + go func() { + defer listener.Close() + for { + conn, err := listener.Accept() + if err != nil { + select { + case <-ctx.Done(): + return + default: + } + log.Errorf(ctx, "Failed to accept connection: %v", err) + continue + } + + go s.handleConnection(ctx, conn) + } + }() + + return port, nil +} + +func (s *TLSServer) handleConnection(ctx context.Context, clientConn net.Conn) { + defer func() { + _ = clientConn.Close() + }() + + // Connect to the target server + targetAddr := fmt.Sprintf("localhost:%d", s.targetPort) + targetConn, err := net.Dial("tcp", targetAddr) + if err != nil { + log.Errorf(ctx, "Failed to connect to target %s: %v", targetAddr, err) + return + } + defer func() { + _ = targetConn.Close() + }() + + // Create channels to wait for either connection to close + clientClosed := make(chan struct{}) + targetClosed := make(chan struct{}) + + // Copy data from client to target + go func() { + _, err := io.Copy(targetConn, clientConn) + if err != nil && !errors.Is(err, io.EOF) { + log.Errorf(ctx, "Error copying from client to target: %v", err) + } + close(clientClosed) + }() + + // Copy data from target to client + go func() { + _, err := io.Copy(clientConn, targetConn) + if err != nil && !errors.Is(err, io.EOF) { + log.Errorf(ctx, "Error copying from target to client: %v", err) + } + close(targetClosed) + }() + + // Wait for either connection to close + select { + case <-clientClosed: + _ = targetConn.Close() + case <-targetClosed: + _ = clientConn.Close() + } +} diff --git a/pkg/nanobot/reverseproxy/tlsclient.go b/pkg/nanobot/reverseproxy/tlsclient.go new file mode 100644 index 00000000..c7a3a2ac --- /dev/null +++ b/pkg/nanobot/reverseproxy/tlsclient.go @@ -0,0 +1,148 @@ +package reverseproxy + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "net" + "os/exec" + "strings" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/log" +) + +// TLSClient represents a non-TLS server that proxies connections to a TLS server +type TLSClient struct { + localPort int + remoteHost string + remotePort int + config *tls.Config +} + +// NewTLSClient creates a new TLS client proxy +func NewTLSClient(localPort int, remoteHost string, remotePort int, caCertPEM, clientCertPEM, clientKeyPEM []byte) (*TLSClient, error) { + // Create certificate pool and add CA certificate + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCertPEM) { + return nil, fmt.Errorf("failed to append CA certificate") + } + + // Load client certificate + cert, err := tls.X509KeyPair(clientCertPEM, clientKeyPEM) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %v", err) + } + + // Create TLS config + config := &tls.Config{ + RootCAs: caCertPool, + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + } + + return &TLSClient{ + localPort: localPort, + remoteHost: remoteHost, + remotePort: remotePort, + config: config, + }, nil +} + +// Start starts the non-TLS server and proxies connections to the TLS server +func (c *TLSClient) Start(ctx context.Context) error { + listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", c.localPort)) + if err != nil { + return fmt.Errorf("failed to create listener: %v", err) + } + defer func() { + _ = listener.Close() + }() + + context.AfterFunc(ctx, func() { + _ = listener.Close() + }) + + log.Infof(ctx, "TLS client proxy listening on localhost:%d, forwarding to %s:%d", c.localPort, c.remoteHost, c.remotePort) + + for { + conn, err := listener.Accept() + if err != nil { + select { + case <-ctx.Done(): + return nil + default: + } + log.Errorf(ctx, "Failed to accept connection: %v", err) + continue + } + + go c.handleConnection(ctx, conn) + } +} + +func (c *TLSClient) handleConnection(ctx context.Context, clientConn net.Conn) { + defer clientConn.Close() + + // Connect to the remote TLS server + remoteAddr := fmt.Sprintf("%s:%d", c.remoteHost, c.remotePort) + tlsConn, err := tls.Dial("tcp", remoteAddr, c.config) + if err != nil { + log.Errorf(ctx, "Failed to connect to remote TLS server %s: %v", remoteAddr, err) + return + } + defer func() { + _ = tlsConn.Close() + }() + + // Create channels to wait for either connection to close + clientClosed := make(chan struct{}) + remoteClosed := make(chan struct{}) + + // Copy data from client to remote + go func() { + _, err := io.Copy(tlsConn, clientConn) + if err != nil && !errors.Is(err, io.EOF) { + log.Errorf(ctx, "Error copying from client to remote: %v", err) + } + close(clientClosed) + }() + + // Copy data from remote to client + go func() { + _, err := io.Copy(clientConn, tlsConn) + if err != nil && !errors.Is(err, io.EOF) { + log.Errorf(ctx, "Error copying from remote to client: %v", err) + } + close(remoteClosed) + }() + + // Wait for either connection to close + select { + case <-clientClosed: + _ = tlsConn.Close() + case <-remoteClosed: + _ = clientConn.Close() + } +} + +// GetGatewayIP returns the default gateway IP address by executing 'ip route' +func GetGatewayIP() (string, error) { + // Execute 'ip route' command to get the default gateway + cmd := exec.Command("ip", "route", "show", "default") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to execute ip route: %v", err) + } + + // Parse the output to find the gateway + // Example output: "default via 192.168.1.1 dev eth0" + fields := strings.Fields(string(output)) + if len(fields) < 3 || fields[0] != "default" || fields[1] != "via" { + return "", fmt.Errorf("unexpected ip route output format") + } + + return fields[2], nil +} diff --git a/pkg/nanobot/supervise/daemon.go b/pkg/nanobot/supervise/daemon.go new file mode 100644 index 00000000..3bb944f2 --- /dev/null +++ b/pkg/nanobot/supervise/daemon.go @@ -0,0 +1,50 @@ +package supervise + +import ( + "context" + "os" + "os/exec" + "time" +) + +func Daemon() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cmd := exec.CommandContext(ctx, os.Args[2], os.Args[3:]...) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + + // Inherit the process group from parent (don't create new one) + // The parent already set up the process group in Cmd() + cmd.Cancel = func() error { + if cmd.Process != nil { + return cmd.Process.Kill() + } + return nil + } + + processIn, err := cmd.StdinPipe() + if err != nil { + return err + } + + go func() { + var buf [4096]byte + for { + n, err := os.Stdin.Read(buf[:]) + if err != nil { + break + } + if n > 0 { + if _, err := processIn.Write(buf[:n]); err != nil { + break + } + } + } + time.Sleep(5 * time.Second) + cancel() + }() + + return cmd.Run() +} diff --git a/pkg/nanobot/supervise/supervise.go b/pkg/nanobot/supervise/supervise.go new file mode 100644 index 00000000..d8166633 --- /dev/null +++ b/pkg/nanobot/supervise/supervise.go @@ -0,0 +1,29 @@ +//go:build !windows + +package supervise + +import ( + "context" + "os/exec" + "syscall" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/system" +) + +func Cmd(ctx context.Context, command string, args ...string) *exec.Cmd { + args = append([]string{"_exec", command}, args...) + cmd := exec.CommandContext(ctx, system.Bin(), args...) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + } + cmd.Cancel = func() error { + if cmd.Process != nil { + // Kill the entire process group + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + + return cmd +} diff --git a/pkg/nanobot/supervise/supervise_windows.go b/pkg/nanobot/supervise/supervise_windows.go new file mode 100644 index 00000000..e433dc67 --- /dev/null +++ b/pkg/nanobot/supervise/supervise_windows.go @@ -0,0 +1,22 @@ +package supervise + +import ( + "context" + "os/exec" + + "github.com/gptscript-ai/gptscript/pkg/nanobot/system" +) + +func Cmd(ctx context.Context, command string, args ...string) *exec.Cmd { + args = append([]string{"_exec", command}, args...) + cmd := exec.CommandContext(ctx, system.Bin(), args...) + + cmd.Cancel = func() error { + if cmd.Process != nil { + return cmd.Process.Kill() + } + return nil + } + + return cmd +} diff --git a/pkg/nanobot/system/currentbin.go b/pkg/nanobot/system/currentbin.go new file mode 100644 index 00000000..6683cc54 --- /dev/null +++ b/pkg/nanobot/system/currentbin.go @@ -0,0 +1,32 @@ +package system + +import ( + "os" + "os/exec" + "path/filepath" +) + +func Bin() string { + if bin := os.Getenv("NANOBOT_BIN"); bin != "" { + return bin + } + return currentBin() +} + +// currentBin returns the executable of yourself to allow forking a new version of yourself +// Copied from github.com/moby/moby/pkg/reexec d25b0bd7ea6ce17ca085c54d5965eeeb66417e52 +func currentBin() string { + name := os.Args[0] + if filepath.Base(name) == name { + if lp, err := exec.LookPath(name); err == nil { + return lp + } + } + // handle conversion of relative paths to absolute + if absName, err := filepath.Abs(name); err == nil { + return absName + } + // if we couldn't get absolute name, return original + // (NOTE: Go only errors on Abs() if os.Getwd fails) + return name +} diff --git a/pkg/nanobot/uuid/uuid.go b/pkg/nanobot/uuid/uuid.go new file mode 100644 index 00000000..99bf110b --- /dev/null +++ b/pkg/nanobot/uuid/uuid.go @@ -0,0 +1,30 @@ +package uuid + +import ( + "crypto/rand" + "encoding/hex" + "io" +) + +func String() string { + var uuid [16]byte + _, err := io.ReadFull(rand.Reader, uuid[:]) + if err != nil { + panic("failed to generate UUID: " + err.Error()) + } + uuid[6] = (uuid[6] & 0x0f) | 0x40 // Version 4 + uuid[8] = (uuid[8] & 0x3f) | 0x80 // Variant is 10 + + var dst [36]byte + hex.Encode(dst[:], uuid[:4]) + dst[8] = '-' + hex.Encode(dst[9:13], uuid[4:6]) + dst[13] = '-' + hex.Encode(dst[14:18], uuid[6:8]) + dst[18] = '-' + hex.Encode(dst[19:23], uuid[8:10]) + dst[23] = '-' + hex.Encode(dst[24:], uuid[10:]) + + return string(dst[:]) +} diff --git a/pkg/nanobot/version/version.go b/pkg/nanobot/version/version.go new file mode 100644 index 00000000..45aec0ea --- /dev/null +++ b/pkg/nanobot/version/version.go @@ -0,0 +1,57 @@ +package version + +import ( + "fmt" + "runtime/debug" +) + +var ( + Tag = "v0.0.0-dev" + BaseImage = "ghcr.io/nanobot-ai/nanobot:main" + Name = "nanobot" +) + +func Get() Version { + return NewVersion(Tag) +} + +type Version struct { + Tag string `json:"tag,omitempty"` + Commit string `json:"commit,omitempty"` + Dirty bool `json:"dirty,omitempty"` +} + +func NewVersion(tag string) Version { + v := Version{ + Tag: tag, + } + v.Commit, v.Dirty = GitCommit() + return v +} + +func (v Version) String() string { + if len(v.Commit) < 12 { + return v.Tag + } else if v.Dirty { + return fmt.Sprintf("%s-%s-dirty", v.Tag, v.Commit[:8]) + } + + return fmt.Sprintf("%s+%s", v.Tag, v.Commit[:8]) +} + +func GitCommit() (commit string, dirty bool) { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "", false + } + for _, setting := range bi.Settings { + switch setting.Key { + case "vcs.modified": + dirty = setting.Value == "true" + case "vcs.revision": + commit = setting.Value + } + } + + return +} diff --git a/pkg/parser/parser.go b/pkg/parser/parser.go index 2b625c90..3ceb47fd 100644 --- a/pkg/parser/parser.go +++ b/pkg/parser/parser.go @@ -10,8 +10,8 @@ import ( "strconv" "strings" - "github.com/gptscript-ai/gptscript/pkg/types" "github.com/google/jsonschema-go/jsonschema" + "github.com/gptscript-ai/gptscript/pkg/types" ) var ( diff --git a/pkg/types/tool.go b/pkg/types/tool.go index 6972f3f8..275e5048 100644 --- a/pkg/types/tool.go +++ b/pkg/types/tool.go @@ -10,9 +10,9 @@ import ( "sort" "strings" + "github.com/google/jsonschema-go/jsonschema" "github.com/google/shlex" "github.com/gptscript-ai/gptscript/pkg/system" - "github.com/google/jsonschema-go/jsonschema" "golang.org/x/exp/maps" )