mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: migrate coderd MCP server to official MCP Go SDK (#28056)
## Stack Context PR 1 of 6 in a stack that migrates every Coder MCP surface from the archived `github.com/mark3labs/mcp-go` library to the official `github.com/modelcontextprotocol/go-sdk` v1.7.0, adding MCP 2026-07-28 support while keeping compatibility with clients speaking 2024-11-05 through 2025-06-18. Stack: #28056 -> #28057 -> #28058 -> #28059 -> #28060 -> #28061 ## Why The coderd Streamable HTTP MCP server (`/api/experimental/mcp/http`) is the foundation layer: it introduces the official SDK dependency and the shared `RegisterSDKTool` helper the CLI server reuses. - The server runs the SDK handler in stateless mode with `JSONResponse: true`, preserving the previous `application/json` POST wire format. GET and DELETE return 405, and no `Mcp-Session-Id` is issued, both permitted by the Streamable HTTP spec. - `DisableLocalhostProtection` is set because coderd commonly listens on loopback behind a reverse proxy with a public Host header; the endpoint's bearer authentication is the relevant access control. - Tool registration builds raw JSON object schemas and omits empty `required`, keeping `tools/list` output byte-identical to the previous server (verified with a golden comparison). - SDK logs are adapted to `cdr.dev/slog/v3`; only warnings and errors are forwarded because the SDK logs several INFO lines per stateless request. - Tests cover the modern 2026-07-28 flow, legacy 2025-06-18 initialize, unsupported protocol version rejection (`-32022`), and non-POST method behavior. ## Known behavior deltas vs the old endpoint Both deltas come from the SDK enforcing the Streamable HTTP spec where mark3labs was lenient, on an experimental endpoint: - POST requests whose `Accept` header lists `application/json` without `text/event-stream` are now rejected with 400 (the spec requires clients to list both; a missing `Accept` header is still tolerated). mark3labs did not validate `Accept` at all. - The old server generated an unvalidated `Mcp-Session-Id` response header; the stateless SDK handler issues none. Clients that merely echo the header back are unaffected. ## Validation Beyond unit/integration tests, a remote dogfood UAT ran protocol conformance against a live dev server built from the stack tip: version negotiation matrix (2024-11-05 through bogus/omitted values), auth, session/method semantics, tool schema sanity, tools/call happy and error paths (unknown tool, schema-violating args, malformed JSON, jsonrpc "1.0"), and a concurrency smoke test. No 500s or connection drops; error shapes are clean JSON-RPC/HTTP errors. > Mux created this PR on Mike's behalf.
This commit is contained in:
+98
-68
@@ -1,19 +1,16 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
stdslog "log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/toolsdk"
|
||||
)
|
||||
@@ -33,40 +30,48 @@ type Server struct {
|
||||
Logger slog.Logger
|
||||
|
||||
// mcpServer is the underlying MCP server
|
||||
mcpServer *server.MCPServer
|
||||
mcpServer *mcp.Server
|
||||
|
||||
// streamableServer handles HTTP transport
|
||||
streamableServer *server.StreamableHTTPServer
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
// NewServer creates a new MCP HTTP server
|
||||
func NewServer(logger slog.Logger) (*Server, error) {
|
||||
// Create the core MCP server
|
||||
mcpSrv := server.NewMCPServer(
|
||||
MCPServerName,
|
||||
buildinfo.Version(),
|
||||
server.WithInstructions(MCPServerInstructions),
|
||||
)
|
||||
mcpSrv := mcp.NewServer(&mcp.Implementation{
|
||||
Name: MCPServerName,
|
||||
Version: buildinfo.Version(),
|
||||
}, &mcp.ServerOptions{
|
||||
Instructions: MCPServerInstructions,
|
||||
Logger: stdslog.New(&slogHandler{logger: logger}),
|
||||
})
|
||||
|
||||
// Create logger adapter for mcp-go
|
||||
mcpLogger := &mcpLoggerAdapter{logger: logger}
|
||||
|
||||
// Create streamable HTTP server with configuration
|
||||
streamableServer := server.NewStreamableHTTPServer(mcpSrv,
|
||||
server.WithHeartbeatInterval(30*time.Second),
|
||||
server.WithLogger(mcpLogger),
|
||||
)
|
||||
// Stateless mode runs each request as its own short-lived session,
|
||||
// omits Mcp-Session-Id, and answers GET and DELETE with 405, all
|
||||
// permitted by the Streamable HTTP transport spec.
|
||||
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
|
||||
return mcpSrv
|
||||
}, &mcp.StreamableHTTPOptions{
|
||||
Stateless: true,
|
||||
// Use application/json instead of SSE framing because Coder
|
||||
// tools emit no notifications and do not need a stream.
|
||||
JSONResponse: true,
|
||||
// coderd often listens on loopback behind a reverse proxy,
|
||||
// which trips the SDK's localhost DNS-rebinding check (loopback
|
||||
// local address with a public Host header). The endpoint's
|
||||
// bearer authentication is the relevant access control.
|
||||
DisableLocalhostProtection: true,
|
||||
})
|
||||
|
||||
return &Server{
|
||||
Logger: logger,
|
||||
mcpServer: mcpSrv,
|
||||
streamableServer: streamableServer,
|
||||
Logger: logger,
|
||||
mcpServer: mcpSrv,
|
||||
handler: handler,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler interface
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.streamableServer.ServeHTTP(w, r)
|
||||
s.handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Register all available MCP tools with the server excluding:
|
||||
@@ -91,7 +96,7 @@ func (s *Server) RegisterTools(client *codersdk.Client, opts ...func(*toolsdk.De
|
||||
continue
|
||||
}
|
||||
|
||||
s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps))
|
||||
RegisterSDKTool(s.mcpServer, tool, toolDeps)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -116,60 +121,85 @@ func (s *Server) RegisterChatGPTTools(client *codersdk.Client, opts ...func(*too
|
||||
continue
|
||||
}
|
||||
|
||||
s.mcpServer.AddTools(mcpFromSDK(tool, toolDeps))
|
||||
RegisterSDKTool(s.mcpServer, tool, toolDeps)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mcpFromSDK adapts a toolsdk.Tool to go-mcp's server.ServerTool
|
||||
func mcpFromSDK(sdkTool toolsdk.GenericTool, tb toolsdk.Deps) server.ServerTool {
|
||||
// RegisterSDKTool registers a [toolsdk.GenericTool] with an MCP server.
|
||||
func RegisterSDKTool(srv *mcp.Server, sdkTool toolsdk.GenericTool, tb toolsdk.Deps) {
|
||||
if sdkTool.Schema.Properties == nil {
|
||||
panic("developer error: schema properties cannot be nil")
|
||||
}
|
||||
|
||||
return server.ServerTool{
|
||||
Tool: mcp.Tool{
|
||||
Name: sdkTool.Name,
|
||||
Description: sdkTool.Description,
|
||||
InputSchema: mcp.ToolInputSchema{
|
||||
Type: "object",
|
||||
Properties: sdkTool.Schema.Properties,
|
||||
Required: sdkTool.Schema.Required,
|
||||
},
|
||||
Annotations: mcp.ToolAnnotation{
|
||||
ReadOnlyHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.ReadOnlyHint),
|
||||
DestructiveHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.DestructiveHint),
|
||||
IdempotentHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.IdempotentHint),
|
||||
OpenWorldHint: mcp.ToBoolPtr(sdkTool.MCPAnnotations.OpenWorldHint),
|
||||
},
|
||||
},
|
||||
Handler: func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(request.Params.Arguments); err != nil {
|
||||
return nil, xerrors.Errorf("failed to encode request arguments: %w", err)
|
||||
}
|
||||
result, err := sdkTool.Handler(ctx, tb, buf.Bytes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.NewTextContent(string(result)),
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
inputSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": sdkTool.Schema.Properties,
|
||||
}
|
||||
if len(sdkTool.Schema.Required) > 0 {
|
||||
inputSchema["required"] = sdkTool.Schema.Required
|
||||
}
|
||||
|
||||
srv.AddTool(&mcp.Tool{
|
||||
Name: sdkTool.Name,
|
||||
Description: sdkTool.Description,
|
||||
InputSchema: inputSchema,
|
||||
// Set pointer-valued hints even when false so every hint
|
||||
// remains explicit on the wire.
|
||||
Annotations: &mcp.ToolAnnotations{
|
||||
ReadOnlyHint: sdkTool.MCPAnnotations.ReadOnlyHint,
|
||||
DestructiveHint: ptr.Ref(sdkTool.MCPAnnotations.DestructiveHint),
|
||||
IdempotentHint: sdkTool.MCPAnnotations.IdempotentHint,
|
||||
OpenWorldHint: ptr.Ref(sdkTool.MCPAnnotations.OpenWorldHint),
|
||||
},
|
||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
result, err := sdkTool.Handler(ctx, tb, req.Params.Arguments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: string(result)},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// mcpLoggerAdapter adapts slog.Logger to the mcp-go util.Logger interface
|
||||
type mcpLoggerAdapter struct {
|
||||
type slogHandler struct {
|
||||
logger slog.Logger
|
||||
}
|
||||
|
||||
func (l *mcpLoggerAdapter) Infof(format string, v ...any) {
|
||||
l.logger.Info(context.Background(), fmt.Sprintf(format, v...))
|
||||
// The SDK logs several INFO lines per stateless request (session
|
||||
// connect/disconnect), so only warnings and errors are forwarded.
|
||||
func (*slogHandler) Enabled(_ context.Context, level stdslog.Level) bool {
|
||||
return level >= stdslog.LevelWarn
|
||||
}
|
||||
|
||||
func (l *mcpLoggerAdapter) Errorf(format string, v ...any) {
|
||||
l.logger.Error(context.Background(), fmt.Sprintf(format, v...))
|
||||
func (h *slogHandler) Handle(ctx context.Context, record stdslog.Record) error {
|
||||
fields := make([]slog.Field, 0, record.NumAttrs())
|
||||
record.Attrs(func(attr stdslog.Attr) bool {
|
||||
fields = append(fields, slog.F(attr.Key, attr.Value.Any()))
|
||||
return true
|
||||
})
|
||||
switch {
|
||||
case record.Level >= stdslog.LevelError:
|
||||
h.logger.Error(ctx, record.Message, fields...)
|
||||
case record.Level >= stdslog.LevelWarn:
|
||||
h.logger.Warn(ctx, record.Message, fields...)
|
||||
default:
|
||||
h.logger.Info(ctx, record.Message, fields...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *slogHandler) WithAttrs(attrs []stdslog.Attr) stdslog.Handler {
|
||||
fields := make([]slog.Field, 0, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
fields = append(fields, slog.F(attr.Key, attr.Value.Any()))
|
||||
}
|
||||
return &slogHandler{logger: h.logger.With(fields...)}
|
||||
}
|
||||
|
||||
func (h *slogHandler) WithGroup(string) stdslog.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
+85
-2
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -81,9 +83,8 @@ func TestMCPHTTP_InitializeRequest(t *testing.T) {
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, recorder.Code)
|
||||
|
||||
// Check that a session ID was returned
|
||||
sessionID := recorder.Header().Get("Mcp-Session-Id")
|
||||
assert.NotEmpty(t, sessionID)
|
||||
assert.Empty(t, sessionID)
|
||||
|
||||
// Parse response
|
||||
var response map[string]any
|
||||
@@ -131,3 +132,85 @@ func TestMCPHTTP_ToolRegistration(t *testing.T) {
|
||||
require.Contains(t, toolNames, toolsdk.ToolNameReportTask, "Should include ReportTask (UserClientOptional)")
|
||||
require.Contains(t, toolNames, toolsdk.ToolNameGetAuthenticatedUser, "Should include GetAuthenticatedUser (requires auth)")
|
||||
}
|
||||
|
||||
func TestMCPHTTP_ModernProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
server, err := mcpserver.NewServer(logger)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(testutil.MustURL(t, "http://not-used"))
|
||||
err = server.RegisterTools(client)
|
||||
require.NoError(t, err)
|
||||
|
||||
ts := httptest.NewServer(server)
|
||||
defer ts.Close()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
mcpClient := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "test-client", Version: "1.0.0"}, nil)
|
||||
session, err := mcpClient.Connect(ctx, &sdkmcp.StreamableClientTransport{
|
||||
Endpoint: ts.URL,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
init := session.InitializeResult()
|
||||
require.Equal(t, "2026-07-28", init.ProtocolVersion)
|
||||
require.Equal(t, mcpserver.MCPServerName, init.ServerInfo.Name)
|
||||
require.Equal(t, mcpserver.MCPServerInstructions, init.Instructions)
|
||||
|
||||
tools, err := session.ListTools(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, tools.Tools)
|
||||
}
|
||||
|
||||
func TestMCPHTTP_UnsupportedProtocolVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
server, err := mcpserver.NewServer(logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{` +
|
||||
`"io.modelcontextprotocol/protocolVersion":"2099-01-01",` +
|
||||
`"io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},` +
|
||||
`"io.modelcontextprotocol/clientCapabilities":{}}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json,text/event-stream")
|
||||
req.Header.Set("MCP-Protocol-Version", "2099-01-01")
|
||||
req.Header.Set("Mcp-Method", "tools/list")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
server.ServeHTTP(recorder, req)
|
||||
|
||||
var response struct {
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response), "body: %s", recorder.Body.String())
|
||||
require.Equal(t, -32022, response.Error.Code)
|
||||
}
|
||||
|
||||
func TestMCPHTTP_TransportMethods(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := testutil.Logger(t)
|
||||
|
||||
server, err := mcpserver.NewServer(logger)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, method := range []string{http.MethodGet, http.MethodDelete} {
|
||||
req := httptest.NewRequest(method, "/", nil)
|
||||
if method == http.MethodGet {
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
server.ServeHTTP(recorder, req)
|
||||
require.Equal(t, http.StatusMethodNotAllowed, recorder.Code, "method %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,10 +514,12 @@ require (
|
||||
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
|
||||
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/linkdata/deadlock v0.5.5 // indirect
|
||||
github.com/minio/simdjson-go v0.4.5 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect
|
||||
github.com/q-uint/parser v0.3.1 // indirect
|
||||
github.com/segmentio/encoding v0.5.4 // indirect
|
||||
github.com/trailofbits/go-mutexasserts v0.0.0-20250514102930-c1f3d2e37561 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.opentelemetry.io/collector/featuregate v1.51.1-0.20260205185216-81bc641f26c0 // indirect
|
||||
@@ -541,6 +543,7 @@ require (
|
||||
github.com/go-git/go-git/v5 v5.19.2
|
||||
github.com/invopop/jsonschema v0.14.0
|
||||
github.com/mark3labs/mcp-go v0.38.0
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||
github.com/nats-io/nats-server/v2 v2.14.2
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
github.com/openai/openai-go/v3 v3.50.0
|
||||
|
||||
@@ -667,6 +667,8 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/
|
||||
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
|
||||
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
|
||||
github.com/google/nftables v0.2.0 h1:PbJwaBmbVLzpeldoeUKGkE2RjstrjPKMl6oLrfEJ6/8=
|
||||
@@ -927,6 +929,8 @@ github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/mocktools/go-smtp-mock/v2 v2.5.0 h1:0wUW3YhTHUO6SEqWczCHpLynwIfXieGtxpWJa44YVCM=
|
||||
github.com/mocktools/go-smtp-mock/v2 v2.5.0/go.mod h1:h9AOf/IXLSU2m/1u4zsjtOM/WddPwdOUBz56dV9f81M=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -1089,6 +1093,8 @@ github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxo
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||
github.com/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M=
|
||||
github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY=
|
||||
github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ=
|
||||
|
||||
Reference in New Issue
Block a user