chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
package utils //nolint:revive //TODO: figure out a better name for this package
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type APIHostResolver interface {
|
||||
BaseRESTURL(ctx context.Context) (*url.URL, error)
|
||||
GraphqlURL(ctx context.Context) (*url.URL, error)
|
||||
UploadURL(ctx context.Context) (*url.URL, error)
|
||||
RawURL(ctx context.Context) (*url.URL, error)
|
||||
AuthorizationServerURL(ctx context.Context) (*url.URL, error)
|
||||
}
|
||||
|
||||
type APIHost struct {
|
||||
restURL *url.URL
|
||||
gqlURL *url.URL
|
||||
uploadURL *url.URL
|
||||
rawURL *url.URL
|
||||
authorizationServerURL *url.URL
|
||||
}
|
||||
|
||||
var _ APIHostResolver = APIHost{}
|
||||
|
||||
func NewAPIHost(s string) (APIHostResolver, error) {
|
||||
a, err := parseAPIHost(s)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// APIHostResolver implementation
|
||||
func (a APIHost) BaseRESTURL(_ context.Context) (*url.URL, error) {
|
||||
return a.restURL, nil
|
||||
}
|
||||
|
||||
func (a APIHost) GraphqlURL(_ context.Context) (*url.URL, error) {
|
||||
return a.gqlURL, nil
|
||||
}
|
||||
|
||||
func (a APIHost) UploadURL(_ context.Context) (*url.URL, error) {
|
||||
return a.uploadURL, nil
|
||||
}
|
||||
|
||||
func (a APIHost) RawURL(_ context.Context) (*url.URL, error) {
|
||||
return a.rawURL, nil
|
||||
}
|
||||
|
||||
func (a APIHost) AuthorizationServerURL(_ context.Context) (*url.URL, error) {
|
||||
return a.authorizationServerURL, nil
|
||||
}
|
||||
|
||||
func newDotcomHost() (APIHost, error) {
|
||||
baseRestURL, err := url.Parse("https://api.github.com/")
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err)
|
||||
}
|
||||
|
||||
gqlURL, err := url.Parse("https://api.github.com/graphql")
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err)
|
||||
}
|
||||
|
||||
uploadURL, err := url.Parse("https://uploads.github.com")
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err)
|
||||
}
|
||||
|
||||
rawURL, err := url.Parse("https://raw.githubusercontent.com/")
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err)
|
||||
}
|
||||
|
||||
// The authorization server for GitHub.com is at github.com/login/oauth, not api.github.com
|
||||
authorizationServerURL, err := url.Parse("https://github.com/login/oauth")
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse dotcom Authorization Server URL: %w", err)
|
||||
}
|
||||
|
||||
return APIHost{
|
||||
restURL: baseRestURL,
|
||||
gqlURL: gqlURL,
|
||||
uploadURL: uploadURL,
|
||||
rawURL: rawURL,
|
||||
authorizationServerURL: authorizationServerURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newGHECHost(hostname string) (APIHost, error) {
|
||||
u, err := url.Parse(hostname)
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err)
|
||||
}
|
||||
|
||||
// Unsecured GHEC would be an error
|
||||
if u.Scheme == "http" {
|
||||
return APIHost{}, fmt.Errorf("GHEC URL must be HTTPS")
|
||||
}
|
||||
|
||||
restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname()))
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err)
|
||||
}
|
||||
|
||||
gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname()))
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err)
|
||||
}
|
||||
|
||||
uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s/", u.Hostname()))
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err)
|
||||
}
|
||||
|
||||
rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname()))
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err)
|
||||
}
|
||||
|
||||
authorizationServerURL, err := url.Parse(fmt.Sprintf("https://%s/login/oauth", u.Hostname()))
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHEC Authorization Server URL: %w", err)
|
||||
}
|
||||
|
||||
return APIHost{
|
||||
restURL: restURL,
|
||||
gqlURL: gqlURL,
|
||||
uploadURL: uploadURL,
|
||||
rawURL: rawURL,
|
||||
authorizationServerURL: authorizationServerURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newGHESHost(hostname string) (APIHost, error) {
|
||||
u, err := url.Parse(hostname)
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHES URL: %w", err)
|
||||
}
|
||||
|
||||
restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname()))
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err)
|
||||
}
|
||||
|
||||
gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname()))
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err)
|
||||
}
|
||||
|
||||
// Check if subdomain isolation is enabled
|
||||
// See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation
|
||||
hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname())
|
||||
|
||||
var uploadURL *url.URL
|
||||
if hasSubdomainIsolation {
|
||||
// With subdomain isolation: https://uploads.hostname/
|
||||
uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname()))
|
||||
} else {
|
||||
// Without subdomain isolation: https://hostname/api/uploads/
|
||||
uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname()))
|
||||
}
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err)
|
||||
}
|
||||
|
||||
var rawURL *url.URL
|
||||
if hasSubdomainIsolation {
|
||||
// With subdomain isolation: https://raw.hostname/
|
||||
rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname()))
|
||||
} else {
|
||||
// Without subdomain isolation: https://hostname/raw/
|
||||
rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname()))
|
||||
}
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err)
|
||||
}
|
||||
|
||||
authorizationServerURL, err := url.Parse(fmt.Sprintf("%s://%s/login/oauth", u.Scheme, u.Hostname()))
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("failed to parse GHES Authorization Server URL: %w", err)
|
||||
}
|
||||
|
||||
return APIHost{
|
||||
restURL: restURL,
|
||||
gqlURL: gqlURL,
|
||||
uploadURL: uploadURL,
|
||||
rawURL: rawURL,
|
||||
authorizationServerURL: authorizationServerURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled
|
||||
// by attempting to ping the raw.<host>/_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation.
|
||||
func checkSubdomainIsolation(scheme, hostname string) bool {
|
||||
subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
// Don't follow redirects - we just want to check if the endpoint exists
|
||||
//nolint:revive // parameters are required by http.Client.CheckRedirect signature
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(subdomainURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// Note that this does not handle ports yet, so development environments are out.
|
||||
func parseAPIHost(s string) (APIHost, error) {
|
||||
if s == "" {
|
||||
return newDotcomHost()
|
||||
}
|
||||
|
||||
u, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return APIHost{}, fmt.Errorf("could not parse host as URL: %s", s)
|
||||
}
|
||||
|
||||
if u.Scheme == "" {
|
||||
return APIHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s)
|
||||
}
|
||||
|
||||
if u.Hostname() == "github.com" || strings.HasSuffix(u.Hostname(), ".github.com") {
|
||||
return newDotcomHost()
|
||||
}
|
||||
|
||||
if u.Hostname() == "ghe.com" || strings.HasSuffix(u.Hostname(), ".ghe.com") {
|
||||
return newGHECHost(s)
|
||||
}
|
||||
|
||||
return newGHESHost(s)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package utils //nolint:revive //TODO: figure out a better name for this package
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseAPIHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantRestURL string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty string defaults to dotcom",
|
||||
input: "",
|
||||
wantRestURL: "https://api.github.com/",
|
||||
},
|
||||
{
|
||||
name: "github.com hostname",
|
||||
input: "https://github.com",
|
||||
wantRestURL: "https://api.github.com/",
|
||||
},
|
||||
{
|
||||
name: "subdomain of github.com",
|
||||
input: "https://foo.github.com",
|
||||
wantRestURL: "https://api.github.com/",
|
||||
},
|
||||
{
|
||||
name: "hostname ending in github.com but not a subdomain",
|
||||
input: "https://mycompanygithub.com",
|
||||
wantRestURL: "https://mycompanygithub.com/api/v3/",
|
||||
},
|
||||
{
|
||||
name: "hostname ending in notgithub.com",
|
||||
input: "https://notgithub.com",
|
||||
wantRestURL: "https://notgithub.com/api/v3/",
|
||||
},
|
||||
{
|
||||
name: "ghe.com hostname",
|
||||
input: "https://ghe.com",
|
||||
wantRestURL: "https://api.ghe.com/",
|
||||
},
|
||||
{
|
||||
name: "subdomain of ghe.com",
|
||||
input: "https://mycompany.ghe.com",
|
||||
wantRestURL: "https://api.mycompany.ghe.com/",
|
||||
},
|
||||
{
|
||||
name: "hostname ending in ghe.com but not a subdomain",
|
||||
input: "https://myghe.com",
|
||||
wantRestURL: "https://myghe.com/api/v3/",
|
||||
},
|
||||
{
|
||||
name: "missing scheme",
|
||||
input: "github.com",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host, err := parseAPIHost(tc.input)
|
||||
if tc.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.wantRestURL, host.restURL.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package utils //nolint:revive //TODO: figure out a better name for this package
|
||||
|
||||
import "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
|
||||
func NewToolResultText(message string) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NewToolResultError(message string) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
func NewToolResultErrorFromErr(message string, err error) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{
|
||||
Text: message + ": " + err.Error(),
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
func NewToolResultResource(message string, contents *mcp.ResourceContents) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{
|
||||
Text: message,
|
||||
},
|
||||
&mcp.EmbeddedResource{
|
||||
Resource: contents,
|
||||
},
|
||||
},
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
func NewToolResultResourceLink(message string, link *mcp.ResourceLink) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{
|
||||
Text: message,
|
||||
},
|
||||
link,
|
||||
},
|
||||
IsError: false,
|
||||
}
|
||||
}
|
||||
|
||||
// NewToolResultAwaitingFormSubmission signals to the agent that a tool call
|
||||
// has been intercepted to show an MCP App form to the user and has NOT
|
||||
// performed the requested operation. The agent must stop, not chain dependent
|
||||
// tool calls, and not claim the operation succeeded. The result is marked
|
||||
// IsError=true so agents that bail on error don't proceed; the host still
|
||||
// renders the UI because rendering is keyed off the tool's _meta.ui, not the
|
||||
// result. The MCP App form will submit the operation directly when the user
|
||||
// clicks submit, after which a ui/update-model-context call delivers the real
|
||||
// outcome to the agent.
|
||||
func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
StructuredContent: map[string]any{
|
||||
"status": "awaiting_user_submission",
|
||||
"reason": "An interactive form is being shown to the user. The operation has not been performed.",
|
||||
},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package utils //nolint:revive //TODO: figure out a better name for this package
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
httpheaders "github.com/github/github-mcp-server/pkg/http/headers"
|
||||
"github.com/github/github-mcp-server/pkg/http/mark"
|
||||
)
|
||||
|
||||
type TokenType int
|
||||
|
||||
const (
|
||||
TokenTypeUnknown TokenType = iota
|
||||
TokenTypePersonalAccessToken
|
||||
TokenTypeFineGrainedPersonalAccessToken
|
||||
TokenTypeOAuthAccessToken
|
||||
TokenTypeUserToServerGitHubAppToken
|
||||
TokenTypeServerToServerGitHubAppToken
|
||||
)
|
||||
|
||||
var supportedGitHubPrefixes = map[string]TokenType{
|
||||
"ghp_": TokenTypePersonalAccessToken, // Personal access token (classic)
|
||||
"github_pat_": TokenTypeFineGrainedPersonalAccessToken, // Fine-grained personal access token
|
||||
"gho_": TokenTypeOAuthAccessToken, // OAuth access token
|
||||
"ghu_": TokenTypeUserToServerGitHubAppToken, // User access token for a GitHub App
|
||||
"ghs_": TokenTypeServerToServerGitHubAppToken, // Installation access token for a GitHub App (a.k.a. server-to-server token)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrMissingAuthorizationHeader = fmt.Errorf("%w: missing required Authorization header", mark.ErrBadRequest)
|
||||
ErrBadAuthorizationHeader = fmt.Errorf("%w: Authorization header is badly formatted", mark.ErrBadRequest)
|
||||
ErrUnsupportedAuthorizationHeader = fmt.Errorf("%w: unsupported Authorization header", mark.ErrBadRequest)
|
||||
)
|
||||
|
||||
// oldPatternRegexp is the regular expression for the old pattern of the token.
|
||||
// Until 2021, GitHub API tokens did not have an identifiable prefix. They
|
||||
// were 40 characters long and only contained the characters a-f and 0-9.
|
||||
var oldPatternRegexp = regexp.MustCompile(`\A[a-f0-9]{40}\z`)
|
||||
|
||||
// ParseAuthorizationHeader parses the Authorization header from the HTTP request
|
||||
func ParseAuthorizationHeader(req *http.Request) (tokenType TokenType, token string, _ error) {
|
||||
authHeader := req.Header.Get(httpheaders.AuthorizationHeader)
|
||||
if authHeader == "" {
|
||||
return 0, "", ErrMissingAuthorizationHeader
|
||||
}
|
||||
|
||||
switch {
|
||||
// decrypt dotcom token and set it as token
|
||||
case strings.HasPrefix(authHeader, "GitHub-Bearer "):
|
||||
return 0, "", ErrUnsupportedAuthorizationHeader
|
||||
default:
|
||||
// support both "Bearer" and "bearer" to conform to api.github.com
|
||||
if len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "Bearer ") {
|
||||
token = authHeader[7:]
|
||||
} else {
|
||||
token = authHeader
|
||||
}
|
||||
}
|
||||
|
||||
for prefix, tokenType := range supportedGitHubPrefixes {
|
||||
if strings.HasPrefix(token, prefix) {
|
||||
return tokenType, token, nil
|
||||
}
|
||||
}
|
||||
|
||||
matchesOldTokenPattern := oldPatternRegexp.MatchString(token)
|
||||
if matchesOldTokenPattern {
|
||||
return TokenTypePersonalAccessToken, token, nil
|
||||
}
|
||||
|
||||
return 0, "", ErrBadAuthorizationHeader
|
||||
}
|
||||
Reference in New Issue
Block a user