new builtin servers

This commit is contained in:
Ed Zynda
2025-06-24 16:53:48 +03:00
parent d9983b9524
commit 84fd21fc42
9 changed files with 1356 additions and 12 deletions
+27 -1
View File
@@ -54,4 +54,30 @@ MCPHost supports a simplified configuration schema with three server types:
### Configuration Files
- Primary: `~/.mcphost.yml` or `~/.mcphost.json`
- Legacy: `~/.mcp.yml` or `~/.mcp.json`
- Custom location via `--config` flag
- Custom location via `--config` flag
## Available Builtin Servers
MCPHost includes several builtin MCP servers for common functionality:
### Filesystem Server (`fs`)
- **Location**: `internal/builtin/registry.go` (registration), uses external `mcp-filesystem-server`
- **Purpose**: Secure filesystem access with configurable allowed directories
- **Options**: `allowed_directories` array (defaults to current working directory)
### Bash Server (`bash`)
- **Location**: `internal/builtin/bash.go`
- **Purpose**: Execute bash commands with security restrictions and timeout controls
- **Features**: Banned commands list, configurable timeouts, output size limits
- **Security**: Blocks network commands (curl, wget, etc.), 30KB output limit, 2-10 minute timeouts
### Todo Server (`todo`)
- **Location**: `internal/builtin/todo.go`
- **Purpose**: Manage ephemeral todo lists for task tracking during sessions
- **Features**: In-memory storage, thread-safe operations, JSON-based API
- **Tools**: `todowrite` (create/update todos), `todoread` (read current todos)
### Fetch Server (`fetch`)
- **Location**: `internal/builtin/fetch.go`
- **Purpose**: Fetch web content and convert to text, markdown, or HTML formats
- **Features**: HTTP/HTTPS support, HTML parsing, markdown conversion, size limits
- **Security**: 5MB response limit, configurable timeouts, localhost-aware HTTPS upgrade
+34
View File
@@ -183,6 +183,40 @@ Each builtin server entry requires:
**Available Builtin Servers:**
- `fs` (filesystem): Secure filesystem access with configurable allowed directories
- `allowed_directories`: Array of directory paths that the server can access (defaults to current working directory if not specified)
- `bash`: Execute bash commands with security restrictions and timeout controls
- No configuration options required
- `todo`: Manage ephemeral todo lists for task tracking during sessions
- No configuration options required (todos are stored in memory and reset on restart)
- `fetch`: Fetch web content and convert to text, markdown, or HTML formats
- No configuration options required
#### Builtin Server Examples
```json
{
"mcpServers": {
"filesystem": {
"type": "builtin",
"name": "fs",
"options": {
"allowed_directories": ["/tmp", "/home/user/documents"]
}
},
"bash-commands": {
"type": "builtin",
"name": "bash"
},
"task-manager": {
"type": "builtin",
"name": "todo"
},
"web-fetcher": {
"type": "builtin",
"name": "fetch"
}
}
}
```
### Tool Filtering
+7 -3
View File
@@ -27,7 +27,10 @@ require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.15.0 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.33.0 // indirect
@@ -107,9 +110,10 @@ require (
github.com/yuin/goldmark-emoji v1.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.12.0 // indirect
+96 -8
View File
@@ -4,8 +4,13 @@ cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
@@ -13,6 +18,9 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8 h1:ss/c/eeyILgoK2sMsTJdcdLdhY3wZSt//+nanM41B9w=
github.com/anthropics/anthropic-sdk-go v0.2.0-alpha.8/go.mod h1:GJxtdOs9K4neo8Gg65CjJ7jNautmldGli5/OFNabOoo=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
@@ -101,6 +109,7 @@ github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250605072634-0f875e04269d
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250605072634-0f875e04269d/go.mod h1:XKWiwOSkHWN23yd14et8A9khSKSYACU0xu4caySDA5U=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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=
@@ -149,6 +158,7 @@ github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -249,6 +259,7 @@ github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0V
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -263,6 +274,9 @@ github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtIS
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
@@ -280,6 +294,8 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
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.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -291,6 +307,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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=
@@ -324,6 +341,7 @@ github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
@@ -333,16 +351,16 @@ go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJyS
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
@@ -352,29 +370,98 @@ go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTV
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
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.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genai v1.10.0 h1:ETP0Yksn5KUSEn5+ihMOnP3IqjZ+7Z4i0LjJslEXatI=
google.golang.org/genai v1.10.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c=
@@ -385,6 +472,7 @@ google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9x
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+267
View File
@@ -0,0 +1,267 @@
package builtin
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/JohannesKaufmann/html-to-markdown"
"github.com/PuerkitoBio/goquery"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
maxResponseSize = 5 * 1024 * 1024 // 5MB
defaultFetchTimeout = 30 * time.Second
maxFetchTimeout = 120 * time.Second
)
// NewFetchServer creates a new fetch MCP server
func NewFetchServer() (*server.MCPServer, error) {
s := server.NewMCPServer("fetch-server", "1.0.0", server.WithToolCapabilities(true))
// Register the fetch tool
fetchTool := mcp.NewTool("fetch",
mcp.WithDescription(fetchDescription),
mcp.WithString("url",
mcp.Required(),
mcp.Description("The URL to fetch content from"),
),
mcp.WithString("format",
mcp.Required(),
mcp.Enum("text", "markdown", "html"),
mcp.Description("The format to return the content in (text, markdown, or html)"),
),
mcp.WithNumber("timeout",
mcp.Description("Optional timeout in seconds (max 120)"),
mcp.Min(0),
mcp.Max(120),
),
)
s.AddTool(fetchTool, executeFetch)
return s, nil
}
// executeFetch handles the fetch tool execution
func executeFetch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Extract parameters
urlStr, err := request.RequireString("url")
if err != nil {
return mcp.NewToolResultError("url parameter is required and must be a string"), nil
}
format, err := request.RequireString("format")
if err != nil {
return mcp.NewToolResultError("format parameter is required and must be a string"), nil
}
// Validate format
if format != "text" && format != "markdown" && format != "html" {
return mcp.NewToolResultError("format must be 'text', 'markdown', or 'html'"), nil
}
// Parse timeout (optional)
timeout := defaultFetchTimeout
if timeoutSec := request.GetFloat("timeout", 0); timeoutSec > 0 {
timeoutDuration := time.Duration(timeoutSec) * time.Second
if timeoutDuration > maxFetchTimeout {
timeout = maxFetchTimeout
} else {
timeout = timeoutDuration
}
}
// Validate URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL: %v", err)), nil
}
// Ensure URL has a scheme
if parsedURL.Scheme == "" {
urlStr = "https://" + urlStr
parsedURL, err = url.Parse(urlStr)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid URL after adding https: %v", err)), nil
}
}
// Only allow HTTP and HTTPS
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return mcp.NewToolResultError("URL must use http:// or https://"), nil
}
// Upgrade HTTP to HTTPS only for external URLs (not localhost/127.0.0.1)
if parsedURL.Scheme == "http" && !isLocalhost(parsedURL.Host) {
parsedURL.Scheme = "https"
urlStr = parsedURL.String()
}
// Create HTTP client with timeout
client := &http.Client{
Timeout: timeout,
}
// Create request with context
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to create request: %v", err)), nil
}
// Set headers to mimic a real browser
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
// Make the request
resp, err := client.Do(req)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("request failed: %v", err)), nil
}
defer resp.Body.Close()
// Check status code
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return mcp.NewToolResultError(fmt.Sprintf("request failed with status code: %d", resp.StatusCode)), nil
}
// Check content length
if resp.ContentLength > maxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
// Read response body with size limit
limitedReader := io.LimitReader(resp.Body, maxResponseSize+1)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to read response: %v", err)), nil
}
// Check if we exceeded the size limit
if len(bodyBytes) > maxResponseSize {
return mcp.NewToolResultError("response too large (exceeds 5MB limit)"), nil
}
content := string(bodyBytes)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "unknown"
}
// Process content based on format
var output string
switch format {
case "text":
if strings.Contains(contentType, "text/html") {
output, err = extractTextFromHTML(content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to extract text from HTML: %v", err)), nil
}
} else {
output = content
}
case "markdown":
if strings.Contains(contentType, "text/html") {
output, err = convertHTMLToMarkdown(content)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("failed to convert HTML to markdown: %v", err)), nil
}
} else {
output = "```\n" + content + "\n```"
}
case "html":
output = content
default:
output = content
}
// Create result with metadata
title := fmt.Sprintf("%s (%s)", urlStr, contentType)
result := mcp.NewToolResultText(output)
result.Meta = map[string]any{
"title": title,
}
return result, nil
}
// extractTextFromHTML extracts plain text from HTML content
func extractTextFromHTML(htmlContent string) (string, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
return "", err
}
// Remove script, style, and other non-content elements
doc.Find("script, style, noscript, iframe, object, embed").Remove()
// Extract text content
text := doc.Text()
// Clean up whitespace
lines := strings.Split(text, "\n")
var cleanLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
cleanLines = append(cleanLines, trimmed)
}
}
return strings.Join(cleanLines, "\n"), nil
}
// convertHTMLToMarkdown converts HTML content to markdown
func convertHTMLToMarkdown(htmlContent string) (string, error) {
converter := md.NewConverter("", true, nil)
// Remove unwanted elements
converter.Remove("script")
converter.Remove("style")
converter.Remove("meta")
converter.Remove("link")
markdown, err := converter.ConvertString(htmlContent)
if err != nil {
return "", err
}
return markdown, nil
}
// isLocalhost checks if the host is localhost or 127.0.0.1
func isLocalhost(host string) bool {
return strings.HasPrefix(host, "localhost") ||
strings.HasPrefix(host, "127.0.0.1") ||
strings.HasPrefix(host, "::1")
}
const fetchDescription = `Fetches content from a specified URL and returns it in the requested format.
- Fetches content from a specified URL
- Takes a URL and format as input
- Fetches the URL content, converts HTML to markdown or text as requested
- Returns the content in the specified format
- Use this tool when you need to retrieve and analyze web content
Usage notes:
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".
- The URL must be a fully-formed valid URL
- HTTP URLs will be automatically upgraded to HTTPS
- This tool is read-only and does not modify any files
- Results may be summarized if the content is very large (max 5MB)
- Supports three output formats:
- "text": Plain text extraction from HTML, or raw content for non-HTML
- "markdown": HTML converted to markdown, or code-wrapped for non-HTML
- "html": Raw HTML content
- Timeout can be specified in seconds (default 30s, max 120s)`
+365
View File
@@ -0,0 +1,365 @@
package builtin
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestNewFetchServer(t *testing.T) {
server, err := NewFetchServer()
if err != nil {
t.Fatalf("Failed to create fetch server: %v", err)
}
if server == nil {
t.Fatal("Expected server to be non-nil")
}
}
func TestFetchServerRegistry(t *testing.T) {
registry := NewRegistry()
// Test that fetch server is registered
servers := registry.ListServers()
found := false
for _, name := range servers {
if name == "fetch" {
found = true
break
}
}
if !found {
t.Error("fetch server not found in registry")
}
// Test creating fetch server through registry
wrapper, err := registry.CreateServer("fetch", map[string]any{})
if err != nil {
t.Fatalf("Failed to create fetch server through registry: %v", err)
}
if wrapper == nil {
t.Fatal("Expected wrapper to be non-nil")
}
if wrapper.GetServer() == nil {
t.Fatal("Expected wrapped server to be non-nil")
}
}
func TestFetchHTML(t *testing.T) {
// Create a test HTTP server
testHTML := `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
<script>console.log('test');</script>
<style>body { color: red; }</style>
</head>
<body>
<h1>Hello World</h1>
<p>This is a test paragraph.</p>
<script>alert('should be removed');</script>
</body>
</html>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(testHTML))
}))
defer server.Close()
// Test HTML format
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": server.URL,
"format": "html",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Failed to execute fetch: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that we got HTML content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty HTML content")
}
// Should contain the original HTML
if !strings.Contains(textContent.Text, "<h1>Hello World</h1>") {
t.Error("Expected HTML content to contain original markup")
}
} else {
t.Error("Expected text content")
}
}
func TestFetchText(t *testing.T) {
// Create a test HTTP server
testHTML := `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
<script>console.log('test');</script>
<style>body { color: red; }</style>
</head>
<body>
<h1>Hello World</h1>
<p>This is a test paragraph.</p>
<script>alert('should be removed');</script>
</body>
</html>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(testHTML))
}))
defer server.Close()
// Test text format
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": server.URL,
"format": "text",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Failed to execute fetch: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that we got text content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty text content")
}
// Should contain the text but not HTML tags
if !strings.Contains(textContent.Text, "Hello World") {
t.Error("Expected text content to contain 'Hello World'")
}
if strings.Contains(textContent.Text, "<h1>") {
t.Error("Expected text content to not contain HTML tags")
}
if strings.Contains(textContent.Text, "console.log") {
t.Error("Expected text content to not contain script content")
}
} else {
t.Error("Expected text content")
}
}
func TestFetchMarkdown(t *testing.T) {
// Create a test HTTP server
testHTML := `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a test paragraph.</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
</body>
</html>`
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(testHTML))
}))
defer server.Close()
// Test markdown format
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": server.URL,
"format": "markdown",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Failed to execute fetch: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that we got markdown content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty markdown content")
}
// Should contain markdown formatting
if !strings.Contains(textContent.Text, "# Hello World") {
t.Error("Expected markdown content to contain '# Hello World'")
}
} else {
t.Error("Expected text content")
}
}
func TestFetchInvalidURL(t *testing.T) {
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": "not-a-valid-url",
"format": "text",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestFetchInvalidFormat(t *testing.T) {
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": "https://example.com",
"format": "invalid",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestFetchPlainText(t *testing.T) {
// Create a test HTTP server that returns plain text
testText := "This is plain text content.\nWith multiple lines.\nAnd some more text."
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte(testText))
}))
defer server.Close()
// Test text format with plain text content
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "fetch",
Arguments: map[string]any{
"url": server.URL,
"format": "text",
},
},
}
ctx := context.Background()
result, err := executeFetch(ctx, request)
if err != nil {
t.Fatalf("Failed to execute fetch: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that we got the original text content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text != testText {
t.Errorf("Expected '%s', got '%s'", testText, textContent.Text)
}
} else {
t.Error("Expected text content")
}
}
+28
View File
@@ -38,6 +38,8 @@ func NewRegistry() *Registry {
// Register builtin servers
r.registerFilesystemServer()
r.registerBashServer()
r.registerTodoServer()
r.registerFetchServer()
return r
}
@@ -115,3 +117,29 @@ func (r *Registry) registerBashServer() {
return &BuiltinServerWrapper{server: server}, nil
}
}
// registerTodoServer registers the todo server
func (r *Registry) registerTodoServer() {
r.servers["todo"] = func(options map[string]any) (*BuiltinServerWrapper, error) {
// Create the todo server
server, err := NewTodoServer()
if err != nil {
return nil, fmt.Errorf("failed to create todo server: %v", err)
}
return &BuiltinServerWrapper{server: server}, nil
}
}
// registerFetchServer registers the fetch server
func (r *Registry) registerFetchServer() {
r.servers["fetch"] = func(options map[string]any) (*BuiltinServerWrapper, error) {
// Create the fetch server
server, err := NewFetchServer()
if err != nil {
return nil, fmt.Errorf("failed to create fetch server: %v", err)
}
return &BuiltinServerWrapper{server: server}, nil
}
}
+234
View File
@@ -0,0 +1,234 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// TodoInfo represents a single todo item
type TodoInfo struct {
Content string `json:"content"`
Status string `json:"status"`
Priority string `json:"priority"`
ID string `json:"id"`
}
// TodoServer implements a todo management MCP server with in-memory storage
type TodoServer struct {
todos []TodoInfo
mutex sync.RWMutex
}
// NewTodoServer creates a new todo MCP server with in-memory storage
func NewTodoServer() (*server.MCPServer, error) {
todoServer := &TodoServer{
todos: make([]TodoInfo, 0),
}
s := server.NewMCPServer("todo-server", "1.0.0", server.WithToolCapabilities(true))
// Register todowrite tool
todoWriteTool := mcp.NewTool("todowrite",
mcp.WithDescription(todoWriteDescription),
mcp.WithArray("todos",
mcp.Required(),
mcp.Description("The updated todo list"),
mcp.Items(map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{
"type": "string",
"minLength": 1,
"description": "Brief description of the task",
},
"status": map[string]any{
"type": "string",
"enum": []string{"pending", "in_progress", "completed"},
"description": "Current status of the task",
},
"priority": map[string]any{
"type": "string",
"enum": []string{"high", "medium", "low"},
"description": "Priority level of the task",
},
"id": map[string]any{
"type": "string",
"description": "Unique identifier for the todo item",
},
},
"required": []string{"content", "status", "priority", "id"},
}),
),
)
// Register todoread tool
todoReadTool := mcp.NewTool("todoread",
mcp.WithDescription("Use this tool to read your todo list"),
)
s.AddTool(todoWriteTool, todoServer.executeTodoWrite)
s.AddTool(todoReadTool, todoServer.executeTodoRead)
return s, nil
}
// getTodos retrieves all todos from memory
func (ts *TodoServer) getTodos() []TodoInfo {
ts.mutex.RLock()
defer ts.mutex.RUnlock()
// Return a copy to avoid race conditions
todos := make([]TodoInfo, len(ts.todos))
copy(todos, ts.todos)
return todos
}
// setTodos stores todos in memory
func (ts *TodoServer) setTodos(todos []TodoInfo) {
ts.mutex.Lock()
defer ts.mutex.Unlock()
ts.todos = make([]TodoInfo, len(todos))
copy(ts.todos, todos)
}
// executeTodoWrite handles the todowrite tool execution
func (ts *TodoServer) executeTodoWrite(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Parse todos from arguments
todosArg := request.GetArguments()["todos"]
if todosArg == nil {
return mcp.NewToolResultError("todos parameter is required"), nil
}
// Convert to JSON and back to ensure proper structure
todosJSON, err := json.Marshal(todosArg)
if err != nil {
return mcp.NewToolResultError("invalid todos format"), nil
}
var todos []TodoInfo
if err := json.Unmarshal(todosJSON, &todos); err != nil {
return mcp.NewToolResultError("invalid todos structure"), nil
}
// Validate todos
for i, todo := range todos {
if strings.TrimSpace(todo.Content) == "" {
return mcp.NewToolResultError(fmt.Sprintf("todo %d: content cannot be empty", i)), nil
}
if todo.Status != "pending" && todo.Status != "in_progress" && todo.Status != "completed" {
return mcp.NewToolResultError(fmt.Sprintf("todo %d: invalid status '%s'", i, todo.Status)), nil
}
if todo.Priority != "high" && todo.Priority != "medium" && todo.Priority != "low" {
return mcp.NewToolResultError(fmt.Sprintf("todo %d: invalid priority '%s'", i, todo.Priority)), nil
}
if strings.TrimSpace(todo.ID) == "" {
return mcp.NewToolResultError(fmt.Sprintf("todo %d: id cannot be empty", i)), nil
}
}
// Store todos in memory
ts.setTodos(todos)
// Count non-completed todos
activeTodos := 0
for _, todo := range todos {
if todo.Status != "completed" {
activeTodos++
}
}
// Format output
output, err := json.MarshalIndent(todos, "", " ")
if err != nil {
return mcp.NewToolResultError("failed to format todos"), nil
}
// Create result with metadata
result := mcp.NewToolResultText(string(output))
result.Meta = map[string]any{
"title": fmt.Sprintf("%d todos", activeTodos),
"todos": todos,
}
return result, nil
}
// executeTodoRead handles the todoread tool execution
func (ts *TodoServer) executeTodoRead(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Get todos from memory
todos := ts.getTodos()
// Count non-completed todos
activeTodos := 0
for _, todo := range todos {
if todo.Status != "completed" {
activeTodos++
}
}
// Format output
output, err := json.MarshalIndent(todos, "", " ")
if err != nil {
return mcp.NewToolResultError("failed to format todos"), nil
}
// Create result with metadata
result := mcp.NewToolResultText(string(output))
result.Meta = map[string]any{
"title": fmt.Sprintf("%d todos", activeTodos),
"todos": todos,
}
return result, nil
}
const todoWriteDescription = `Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user.
It also helps the user understand the progress of the task and overall progress of their requests.
## When to Use This Tool
Use this tool proactively in these scenarios:
1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions
2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations
3. User explicitly requests todo list - When the user directly asks you to use the todo list
4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated)
5. After receiving new instructions - Immediately capture user requirements as todos. Feel free to edit the todo list based on new information.
6. After completing a task - Mark it complete and add any new follow-up tasks
7. When you start working on a new task, mark the todo as in_progress. Ideally you should only have one todo as in_progress at a time. Complete existing tasks before starting new ones.
## When NOT to Use This Tool
Skip using this tool when:
1. There is only a single, straightforward task
2. The task is trivial and tracking it provides no organizational benefit
3. The task can be completed in less than 3 trivial steps
4. The task is purely conversational or informational
NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly.
## Task States and Management
1. **Task States**: Use these states to track progress:
- pending: Task not yet started
- in_progress: Currently working on (limit to ONE task at a time)
- completed: Task finished successfully
2. **Task Management**:
- Update task status in real-time as you work
- Mark tasks complete IMMEDIATELY after finishing (don't batch completions)
- Only have ONE task in_progress at any time
- Complete current tasks before starting new ones
3. **Task Breakdown**:
- Create specific, actionable items
- Break complex tasks into smaller, manageable steps
- Use clear, descriptive task names
When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully.`
+298
View File
@@ -0,0 +1,298 @@
package builtin
import (
"context"
"encoding/json"
"testing"
"github.com/mark3labs/mcp-go/mcp"
)
func TestNewTodoServer(t *testing.T) {
server, err := NewTodoServer()
if err != nil {
t.Fatalf("Failed to create todo server: %v", err)
}
if server == nil {
t.Fatal("Expected server to be non-nil")
}
}
func TestTodoServerRegistry(t *testing.T) {
registry := NewRegistry()
// Test that todo server is registered
servers := registry.ListServers()
found := false
for _, name := range servers {
if name == "todo" {
found = true
break
}
}
if !found {
t.Error("todo server not found in registry")
}
// Test creating todo server through registry
wrapper, err := registry.CreateServer("todo", map[string]any{})
if err != nil {
t.Fatalf("Failed to create todo server through registry: %v", err)
}
if wrapper == nil {
t.Fatal("Expected wrapper to be non-nil")
}
if wrapper.GetServer() == nil {
t.Fatal("Expected wrapped server to be non-nil")
}
}
func TestTodoWrite(t *testing.T) {
server := &TodoServer{
todos: make([]TodoInfo, 0),
}
// Create a test request with valid todos
todos := []TodoInfo{
{
Content: "Test task 1",
Status: "pending",
Priority: "high",
ID: "1",
},
{
Content: "Test task 2",
Status: "in_progress",
Priority: "medium",
ID: "2",
},
}
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todowrite",
Arguments: map[string]any{
"todos": todos,
},
},
}
ctx := context.Background()
result, err := server.executeTodoWrite(ctx, request)
if err != nil {
t.Fatalf("Failed to execute todowrite: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Check that todos were stored
storedTodos := server.getTodos()
if len(storedTodos) != 2 {
t.Errorf("Expected 2 todos, got %d", len(storedTodos))
}
// Verify the content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
var resultTodos []TodoInfo
if err := json.Unmarshal([]byte(textContent.Text), &resultTodos); err != nil {
t.Errorf("Failed to parse result JSON: %v", err)
} else if len(resultTodos) != 2 {
t.Errorf("Expected 2 todos in result, got %d", len(resultTodos))
}
} else {
t.Error("Expected text content")
}
}
func TestTodoRead(t *testing.T) {
server := &TodoServer{
todos: []TodoInfo{
{
Content: "Existing task",
Status: "pending",
Priority: "low",
ID: "existing-1",
},
},
}
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todoread",
},
}
ctx := context.Background()
result, err := server.executeTodoRead(ctx, request)
if err != nil {
t.Fatalf("Failed to execute todoread: %v", err)
}
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
// Verify the content
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
var resultTodos []TodoInfo
if err := json.Unmarshal([]byte(textContent.Text), &resultTodos); err != nil {
t.Errorf("Failed to parse result JSON: %v", err)
} else if len(resultTodos) != 1 {
t.Errorf("Expected 1 todo in result, got %d", len(resultTodos))
} else if resultTodos[0].Content != "Existing task" {
t.Errorf("Expected 'Existing task', got '%s'", resultTodos[0].Content)
}
} else {
t.Error("Expected text content")
}
}
func TestTodoValidation(t *testing.T) {
server := &TodoServer{
todos: make([]TodoInfo, 0),
}
// Test invalid status
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todowrite",
Arguments: map[string]any{
"todos": []TodoInfo{
{
Content: "Test task",
Status: "invalid_status",
Priority: "high",
ID: "1",
},
},
},
},
}
ctx := context.Background()
result, err := server.executeTodoWrite(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestTodoEmptyContent(t *testing.T) {
server := &TodoServer{
todos: make([]TodoInfo, 0),
}
// Test empty content
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todowrite",
Arguments: map[string]any{
"todos": []TodoInfo{
{
Content: "",
Status: "pending",
Priority: "high",
ID: "1",
},
},
},
},
}
ctx := context.Background()
result, err := server.executeTodoWrite(ctx, request)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Should return an error result
if result == nil {
t.Fatal("Expected result to be non-nil")
}
if len(result.Content) == 0 {
t.Fatal("Expected result to have content")
}
if textContent, ok := mcp.AsTextContent(result.Content[0]); ok {
if textContent.Text == "" {
t.Error("Expected non-empty error message")
}
}
}
func TestTodoActiveCounting(t *testing.T) {
server := &TodoServer{
todos: make([]TodoInfo, 0),
}
// Create todos with different statuses
todos := []TodoInfo{
{Content: "Task 1", Status: "pending", Priority: "high", ID: "1"},
{Content: "Task 2", Status: "in_progress", Priority: "medium", ID: "2"},
{Content: "Task 3", Status: "completed", Priority: "low", ID: "3"},
{Content: "Task 4", Status: "pending", Priority: "high", ID: "4"},
}
request := mcp.CallToolRequest{
Params: mcp.CallToolParams{
Name: "todowrite",
Arguments: map[string]any{
"todos": todos,
},
},
}
ctx := context.Background()
result, err := server.executeTodoWrite(ctx, request)
if err != nil {
t.Fatalf("Failed to execute todowrite: %v", err)
}
// Check metadata for correct active count (should be 3: 2 pending + 1 in_progress)
if result.Meta == nil {
t.Fatal("Expected metadata to be non-nil")
}
title, ok := result.Meta["title"].(string)
if !ok {
t.Fatal("Expected title in metadata")
}
if title != "3 todos" {
t.Errorf("Expected '3 todos', got '%s'", title)
}
}