Files
T
Sam Morrow 6b3c375492 feat: Add Octicon icons to MCP tools, resources, and prompts (#1603)
* Upgrade MCP Go SDK to v1.2.0-pre.1 and add Octicon icons to tools

- Upgrade MCP Go SDK from v1.1.0 to v1.2.0-pre.1 for Icon support
- Add Icon field to ToolsetMetadata for Octicon name assignment
- Add OcticonURL() helper to generate CDN URLs for Octicon SVGs
- Add Icons() method on ToolsetMetadata to generate MCP Icon objects
- Apply icons automatically in RegisterFunc when tool is registered
- Add icons to all 22 toolset metadata constants with appropriate Octicons
- Update server.go to use new Capabilities API (fixes deprecation warnings)

This demonstrates how the toolsets refactor makes adding new features simpler:
icons are defined once in ToolsetMetadata and automatically applied to all
tools in that toolset during registration.

* Update third-party licenses for SDK upgrade

* Address review feedback: enum size validation, mutation fix, tests

- Replace runtime size validation with compile-time enum type (Size with SizeSM=16, SizeLG=24)
- Fix RegisterFunc mutation by making shallow copy of tool before modifying Icons
- Add comprehensive tests for octicons package (URL, Icons, Size constants)
- Add toolsets tests for ToolsetMetadata.Icons(), RegisterFunc mutation prevention,
  and existing icon preservation
- Improve icon choices for better visual semantics:
  - actions: play → workflow (more specific to GitHub Actions)
  - secret_protection: key → shield-lock (better represents protection)
  - gists: code → logo-gist (dedicated gist icon exists)

* Add GitHub mark icon to server metadata

Add the mark-github octicon to the server's Implementation struct
so that MCP clients can display the GitHub logo for this server.
The icon is provided in both 16x16 and 24x24 SVG sizes.

* Fix rebase conflicts: use Registry methods and NullTranslationHelper

- Remove duplicate old toolsets functions (AvailableToolsets, GetValidToolsetIDs, GetDefaultToolsetIDs)
- Use Registry.AvailableToolsets() and Registry.HasToolset() instead
- Replace stubTranslator with translations.NullTranslationHelper
- Use new SDK Capabilities struct instead of deprecated HasTools/HasResources/HasPrompts
- Add icon-related tests to registry_test.go

* Use embedded data URIs for Octicon icons

- Embed SVG icons using go:embed for offline use and faster loading
- Convert icons to base64 data URIs at runtime
- Fall back to CDN URL for non-embedded icons
- Add test to verify all toolset icons are properly embedded
- 44 SVG files (22 icons × 2 sizes) totaling ~27KB

* Convert icons from SVG to PNG for MCP client compatibility

MCP clients don't support SVG data URIs, so convert all embedded icons
to PNG format using rsvg-convert.

Changes:
- Convert all 44 SVG icons to PNG format
- Add 8 new icons: copilot, git-merge, repo-forked, star-fill
- Update octicons.go to use PNG MIME type
- Add script/fetch-icons for easy icon management
- Update tests and toolsnaps for PNG format

* Add mark-github icon for server metadata

* Add light/dark theme icons for tools, resources, and prompts

- Switch from size-based (16/24px) to theme-based (light/dark) icons
- Use only 16x16 icons for smaller bundle size
- Generate white (inverted) icons for dark theme backgrounds
- Add icons to resources and prompts (auto-applied from toolset metadata)
- Add 'file' icon for repository content resources
- Update fetch-icons script to generate both theme variants

* Use 24px icons with SVG fill modification for themes

- Switch from 16px to 24px icons for better visibility
- Use SVG fill attribute (#24292f for light, #ffffff for dark) instead
  of ImageMagick color inversion for cleaner theme variants
- Remove ImageMagick dependency from fetch-icons script

* Add specific icons for each repository resource type

- repository_content: repo icon
- repository_content_branch: git-branch icon
- repository_content_commit: git-commit icon (new)
- repository_content_tag: tag icon
- repository_content_pr: git-pull-request icon

Resources now have explicit icons set rather than relying on toolset fallback.

* fix: restore Icon fields to toolset metadata and add icons to docs

- Add Icon field to all ToolsetMetadata definitions (lost during rebase conflict resolution)
- Update doc generator to include Octicon icons in toolsets table
- Update doc generator to include icons in tool section headers
- Use Primer Octicons CDN for GitHub markdown compatibility

* feat: add icons to individual tools in documentation

* fix: use repo-local icons with picture element for GitHub theme support

- Reference icons from pkg/octicons/icons/ instead of external CDN
- Use picture element with prefers-color-scheme for light/dark mode
- GitHub markdown renderer will display these correctly

* fix: remove redundant icons from individual tools

Icons are kept on section headers and toolsets table only - having the same
icon on every tool within a section was visually noisy and redundant.

* Add icons to remote server toolsets documentation

* Fix icon paths for docs/remote-server.md

* Add remote-only toolsets with auto-generated documentation and icons guide

- Add ToolsetMetadataCopilot, ToolsetMetadataCopilotSpaces, ToolsetMetadataSupportSearch
- Add RemoteOnlyToolsets() function to return remote-only toolset metadata
- Update doc generator to auto-generate remote-only toolsets table with icons
- Create docs/toolsets-and-icons.md explaining how to add icons to toolsets
- Add link to icons guide in CONTRIBUTING.md

* Add icon validation tests and single source of truth for required icons

- Add pkg/octicons/required_icons.txt as single source of truth for icons
- Add RequiredIcons() function to read the required icons list
- Update script/fetch-icons to read from required_icons.txt
- Update octicons_test.go to use RequiredIcons() instead of hardcoded list
- Add pkg/github/toolset_icons_test.go with:
  - TestAllToolsetIconsExist: validates all toolset icons are embedded
  - TestToolsetMetadataHasIcons: ensures all toolsets have icons set
- Add 'book' icon for SupportSearch toolset
- Update docs/toolsets-and-icons.md with fetch-icons and CI validation docs

* fix: remove unused icon parameter from writeToolDoc

- Remove unused 'icon' parameter from writeToolDoc function signature
- Fix whitespace inconsistency in octicons_test.go
- Fixes lint failure: unused-parameter revive error

* fix: combine icon with name column in remote docs for proper table rendering

- Move icon from separate column to Name column with <br> separator
- Keep <picture> element for light/dark theme support
- Remove empty icon column that was collapsing to zero width
- Remove unused octiconSimpleImg function
2025-12-17 17:31:13 +01:00

443 lines
14 KiB
Go

package github
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"github.com/github/github-mcp-server/pkg/octicons"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v79/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// NewServer creates a new GitHub MCP server with the specified GH client and logger.
func NewServer(version string, opts *mcp.ServerOptions) *mcp.Server {
if opts == nil {
opts = &mcp.ServerOptions{}
}
// Create a new MCP server
s := mcp.NewServer(&mcp.Implementation{
Name: "github-mcp-server",
Title: "GitHub MCP Server",
Version: version,
Icons: octicons.Icons("mark-github"),
}, opts)
return s
}
func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) {
switch req.Params.Ref.Type {
case "ref/resource":
if strings.HasPrefix(req.Params.Ref.URI, "repo://") {
return RepositoryResourceCompletionHandler(getClient)(ctx, req)
}
return nil, fmt.Errorf("unsupported resource URI: %s", req.Params.Ref.URI)
case "ref/prompt":
return nil, nil
default:
return nil, fmt.Errorf("unsupported ref type: %s", req.Params.Ref.Type)
}
}
}
// OptionalParamOK is a helper function that can be used to fetch a requested parameter from the request.
// It returns the value, a boolean indicating if the parameter was present, and an error if the type is wrong.
func OptionalParamOK[T any, A map[string]any](args A, p string) (value T, ok bool, err error) {
// Check if the parameter is present in the request
val, exists := args[p]
if !exists {
// Not present, return zero value, false, no error
return
}
// Check if the parameter is of the expected type
value, ok = val.(T)
if !ok {
// Present but wrong type
err = fmt.Errorf("parameter %s is not of type %T, is %T", p, value, val)
ok = true // Set ok to true because the parameter *was* present, even if wrong type
return
}
// Present and correct type
ok = true
return
}
// isAcceptedError checks if the error is an accepted error.
func isAcceptedError(err error) bool {
var acceptedError *github.AcceptedError
return errors.As(err, &acceptedError)
}
// RequiredParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredParam[T comparable](args map[string]any, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
// Check if the parameter is of the expected type
val, ok := args[p].(T)
if !ok {
return zero, fmt.Errorf("parameter %s is not of type %T", p, zero)
}
if val == zero {
return zero, fmt.Errorf("missing required parameter: %s", p)
}
return val, nil
}
// RequiredInt is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type.
// 3. Checks if the parameter is not empty, i.e: non-zero value
func RequiredInt(args map[string]any, p string) (int, error) {
v, err := RequiredParam[float64](args, p)
if err != nil {
return 0, err
}
return int(v), nil
}
// RequiredBigInt is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request.
// 2. Checks if the parameter is of the expected type (float64).
// 3. Checks if the parameter is not empty, i.e: non-zero value.
// 4. Validates that the float64 value can be safely converted to int64 without truncation.
func RequiredBigInt(args map[string]any, p string) (int64, error) {
v, err := RequiredParam[float64](args, p)
if err != nil {
return 0, err
}
result := int64(v)
// Check if converting back produces the same value to avoid silent truncation
if float64(result) != v {
return 0, fmt.Errorf("parameter %s value %f is too large to fit in int64", p, v)
}
return result, nil
}
// OptionalParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, it checks if the parameter is of the expected type and returns it
func OptionalParam[T any](args map[string]any, p string) (T, error) {
var zero T
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return zero, nil
}
// Check if the parameter is of the expected type
if _, ok := args[p].(T); !ok {
return zero, fmt.Errorf("parameter %s is not of type %T, is %T", p, zero, args[p])
}
return args[p].(T), nil
}
// OptionalIntParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, it checks if the parameter is of the expected type and returns it
func OptionalIntParam(args map[string]any, p string) (int, error) {
v, err := OptionalParam[float64](args, p)
if err != nil {
return 0, err
}
return int(v), nil
}
// OptionalIntParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
// similar to optionalIntParam, but it also takes a default value.
func OptionalIntParamWithDefault(args map[string]any, p string, d int) (int, error) {
v, err := OptionalIntParam(args, p)
if err != nil {
return 0, err
}
if v == 0 {
return d, nil
}
return v, nil
}
// OptionalBoolParamWithDefault is a helper function that can be used to fetch a requested parameter from the request
// similar to optionalBoolParam, but it also takes a default value.
func OptionalBoolParamWithDefault(args map[string]any, p string, d bool) (bool, error) {
_, ok := args[p]
v, err := OptionalParam[bool](args, p)
if err != nil {
return false, err
}
if !ok {
return d, nil
}
return v, nil
}
// OptionalStringArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns its zero-value
// 2. If it is present, iterates the elements and checks each is a string
func OptionalStringArrayParam(args map[string]any, p string) ([]string, error) {
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return []string{}, nil
}
switch v := args[p].(type) {
case nil:
return []string{}, nil
case []string:
return v, nil
case []any:
strSlice := make([]string, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []string{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
strSlice[i] = s
}
return strSlice, nil
default:
return []string{}, fmt.Errorf("parameter %s could not be coerced to []string, is %T", p, args[p])
}
}
func convertStringSliceToBigIntSlice(s []string) ([]int64, error) {
int64Slice := make([]int64, len(s))
for i, str := range s {
val, err := convertStringToBigInt(str, 0)
if err != nil {
return nil, fmt.Errorf("failed to convert element %d (%s) to int64: %w", i, str, err)
}
int64Slice[i] = val
}
return int64Slice, nil
}
func convertStringToBigInt(s string, def int64) (int64, error) {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return def, fmt.Errorf("failed to convert string %s to int64: %w", s, err)
}
return v, nil
}
// OptionalBigIntArrayParam is a helper function that can be used to fetch a requested parameter from the request.
// It does the following checks:
// 1. Checks if the parameter is present in the request, if not, it returns an empty slice
// 2. If it is present, iterates the elements, checks each is a string, and converts them to int64 values
func OptionalBigIntArrayParam(args map[string]any, p string) ([]int64, error) {
// Check if the parameter is present in the request
if _, ok := args[p]; !ok {
return []int64{}, nil
}
switch v := args[p].(type) {
case nil:
return []int64{}, nil
case []string:
return convertStringSliceToBigIntSlice(v)
case []any:
int64Slice := make([]int64, len(v))
for i, v := range v {
s, ok := v.(string)
if !ok {
return []int64{}, fmt.Errorf("parameter %s is not of type string, is %T", p, v)
}
val, err := convertStringToBigInt(s, 0)
if err != nil {
return []int64{}, fmt.Errorf("parameter %s: failed to convert element %d (%s) to int64: %w", p, i, s, err)
}
int64Slice[i] = val
}
return int64Slice, nil
default:
return []int64{}, fmt.Errorf("parameter %s could not be coerced to []int64, is %T", p, args[p])
}
}
// WithPagination adds REST API pagination parameters to a tool.
// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["page"] = &jsonschema.Schema{
Type: "number",
Description: "Page number for pagination (min 1)",
Minimum: jsonschema.Ptr(1.0),
}
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
return schema
}
// WithUnifiedPagination adds REST API pagination parameters to a tool.
// GraphQL tools will use this and convert page/perPage to GraphQL cursor parameters internally.
func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["page"] = &jsonschema.Schema{
Type: "number",
Description: "Page number for pagination (min 1)",
Minimum: jsonschema.Ptr(1.0),
}
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
schema.Properties["after"] = &jsonschema.Schema{
Type: "string",
Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
}
return schema
}
// WithCursorPagination adds only cursor-based pagination parameters to a tool (no page parameter).
func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema {
schema.Properties["perPage"] = &jsonschema.Schema{
Type: "number",
Description: "Results per page for pagination (min 1, max 100)",
Minimum: jsonschema.Ptr(1.0),
Maximum: jsonschema.Ptr(100.0),
}
schema.Properties["after"] = &jsonschema.Schema{
Type: "string",
Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.",
}
return schema
}
type PaginationParams struct {
Page int
PerPage int
After string
}
// OptionalPaginationParams returns the "page", "perPage", and "after" parameters from the request,
// or their default values if not present, "page" default is 1, "perPage" default is 30.
// In future, we may want to make the default values configurable, or even have this
// function returned from `withPagination`, where the defaults are provided alongside
// the min/max values.
func OptionalPaginationParams(args map[string]any) (PaginationParams, error) {
page, err := OptionalIntParamWithDefault(args, "page", 1)
if err != nil {
return PaginationParams{}, err
}
perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
if err != nil {
return PaginationParams{}, err
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return PaginationParams{}, err
}
return PaginationParams{
Page: page,
PerPage: perPage,
After: after,
}, nil
}
// OptionalCursorPaginationParams returns the "perPage" and "after" parameters from the request,
// without the "page" parameter, suitable for cursor-based pagination only.
func OptionalCursorPaginationParams(args map[string]any) (CursorPaginationParams, error) {
perPage, err := OptionalIntParamWithDefault(args, "perPage", 30)
if err != nil {
return CursorPaginationParams{}, err
}
after, err := OptionalParam[string](args, "after")
if err != nil {
return CursorPaginationParams{}, err
}
return CursorPaginationParams{
PerPage: perPage,
After: after,
}, nil
}
type CursorPaginationParams struct {
PerPage int
After string
}
// ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters.
func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
if p.PerPage > 100 {
return nil, fmt.Errorf("perPage value %d exceeds maximum of 100", p.PerPage)
}
if p.PerPage < 0 {
return nil, fmt.Errorf("perPage value %d cannot be negative", p.PerPage)
}
first := int32(p.PerPage)
var after *string
if p.After != "" {
after = &p.After
}
return &GraphQLPaginationParams{
First: &first,
After: after,
}, nil
}
type GraphQLPaginationParams struct {
First *int32
After *string
}
// ToGraphQLParams converts REST API pagination parameters to GraphQL-specific parameters.
// This converts page/perPage to first parameter for GraphQL queries.
// If After is provided, it takes precedence over page-based pagination.
func (p PaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) {
// Convert to CursorPaginationParams and delegate to avoid duplication
cursor := CursorPaginationParams{
PerPage: p.PerPage,
After: p.After,
}
return cursor.ToGraphQLParams()
}
func MarshalledTextResult(v any) *mcp.CallToolResult {
data, err := json.Marshal(v)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal text result to json", err)
}
return utils.NewToolResultText(string(data))
}