From 84fd21fc42adf20ca6989949194cbd77263d76b7 Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Tue, 24 Jun 2025 16:53:48 +0300 Subject: [PATCH] new builtin servers --- AGENTS.md | 28 ++- README.md | 34 +++ go.mod | 10 +- go.sum | 104 +++++++++- internal/builtin/fetch.go | 267 ++++++++++++++++++++++++ internal/builtin/fetch_test.go | 365 +++++++++++++++++++++++++++++++++ internal/builtin/registry.go | 28 +++ internal/builtin/todo.go | 234 +++++++++++++++++++++ internal/builtin/todo_test.go | 298 +++++++++++++++++++++++++++ 9 files changed, 1356 insertions(+), 12 deletions(-) create mode 100644 internal/builtin/fetch.go create mode 100644 internal/builtin/fetch_test.go create mode 100644 internal/builtin/todo.go create mode 100644 internal/builtin/todo_test.go diff --git a/AGENTS.md b/AGENTS.md index 35530b87..8d7e481a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 \ No newline at end of file +- 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 \ No newline at end of file diff --git a/README.md b/README.md index 51975938..4cda652b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod index 71c290c3..9e1d2860 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 1b3b67f8..abda9e71 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/builtin/fetch.go b/internal/builtin/fetch.go new file mode 100644 index 00000000..4d1a9e5f --- /dev/null +++ b/internal/builtin/fetch.go @@ -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)` diff --git a/internal/builtin/fetch_test.go b/internal/builtin/fetch_test.go new file mode 100644 index 00000000..0693370d --- /dev/null +++ b/internal/builtin/fetch_test.go @@ -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 := ` + + + Test Page + + + + +

Hello World

+

This is a test paragraph.

+ + +` + + 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, "

Hello World

") { + 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 := ` + + + Test Page + + + + +

Hello World

+

This is a test paragraph.

+ + +` + + 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, "

") { + 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 := ` + + + Test Page + + +

Hello World

+

This is a test paragraph.

+ + +` + + 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") + } +} diff --git a/internal/builtin/registry.go b/internal/builtin/registry.go index 00a6133d..75c4a4eb 100644 --- a/internal/builtin/registry.go +++ b/internal/builtin/registry.go @@ -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 + } +} diff --git a/internal/builtin/todo.go b/internal/builtin/todo.go new file mode 100644 index 00000000..0c01469c --- /dev/null +++ b/internal/builtin/todo.go @@ -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.` diff --git a/internal/builtin/todo_test.go b/internal/builtin/todo_test.go new file mode 100644 index 00000000..4bf76e00 --- /dev/null +++ b/internal/builtin/todo_test.go @@ -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) + } +}