Add tools and update authenticztion and RBAC
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -21,7 +21,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -44,6 +43,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/saferedirect"
|
||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||
"go.probo.inc/probo/pkg/server/session"
|
||||
"go.probo.inc/probo/pkg/statelesstoken"
|
||||
@@ -69,18 +69,10 @@ type (
|
||||
}
|
||||
|
||||
ctxKey struct{ name string }
|
||||
|
||||
userTenantAccess struct {
|
||||
tenantIDs []gid.TenantID
|
||||
authErrors map[gid.TenantID]error
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
sessionContextKey = &ctxKey{name: "session"}
|
||||
userContextKey = &ctxKey{name: "user"}
|
||||
userTenantContextKey = &ctxKey{name: "user_tenants"}
|
||||
userAPIKeyContextKey = &ctxKey{name: "user_api_key"}
|
||||
sessionContextKey = &ctxKey{name: "session"}
|
||||
)
|
||||
|
||||
func SessionFromContext(ctx context.Context) *coredata.Session {
|
||||
@@ -89,13 +81,11 @@ func SessionFromContext(ctx context.Context) *coredata.Session {
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) *coredata.User {
|
||||
user, _ := ctx.Value(userContextKey).(*coredata.User)
|
||||
return user
|
||||
return serverauth.UserFromContext(ctx)
|
||||
}
|
||||
|
||||
func UserAPIKeyFromContext(ctx context.Context) *coredata.UserAPIKey {
|
||||
userAPIKey, _ := ctx.Value(userAPIKeyContextKey).(*coredata.UserAPIKey)
|
||||
return userAPIKey
|
||||
return serverauth.UserAPIKeyFromContext(ctx)
|
||||
}
|
||||
|
||||
func NewMux(
|
||||
@@ -377,7 +367,7 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
if authCtx := tryAPIKeyAuth(ctx, r, authSvc, authzSvc); authCtx != nil {
|
||||
if authCtx := serverauth.AuthenticateWithAPIKey(ctx, r, authSvc, authzSvc); authCtx != nil {
|
||||
next(w, r.WithContext(authCtx))
|
||||
return
|
||||
}
|
||||
@@ -413,10 +403,10 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, sessionContextKey, authResult.Session)
|
||||
ctx = context.WithValue(ctx, userContextKey, authResult.User)
|
||||
ctx = context.WithValue(ctx, userTenantContextKey, &userTenantAccess{
|
||||
tenantIDs: authResult.TenantIDs,
|
||||
authErrors: authResult.AuthErrors,
|
||||
ctx = context.WithValue(ctx, serverauth.UserContextKey, authResult.User)
|
||||
ctx = context.WithValue(ctx, serverauth.UserTenantContextKey, &serverauth.UserTenantAccess{
|
||||
TenantIDs: authResult.TenantIDs,
|
||||
AuthErrors: authResult.AuthErrors,
|
||||
})
|
||||
|
||||
next(w, r.WithContext(ctx))
|
||||
@@ -428,43 +418,6 @@ func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthCon
|
||||
}
|
||||
}
|
||||
|
||||
func tryAPIKeyAuth(ctx context.Context, r *http.Request, authSvc *auth.Service, authzSvc *authz.Service) context.Context {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return nil
|
||||
}
|
||||
|
||||
apiKeyString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
user, userAPIKey, err := authSvc.ValidateUserAPIKey(ctx, apiKeyString)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
organizations, err := authzSvc.GetAllOrganizationsForUserAPIKeyId(ctx, userAPIKey.ID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tenantIDs := make([]gid.TenantID, 0, len(organizations))
|
||||
for _, org := range organizations {
|
||||
tenantIDs = append(tenantIDs, org.ID.TenantID())
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, userContextKey, user)
|
||||
ctx = context.WithValue(ctx, userAPIKeyContextKey, userAPIKey)
|
||||
ctx = context.WithValue(ctx, userTenantContextKey, &userTenantAccess{
|
||||
tenantIDs: tenantIDs,
|
||||
authErrors: make(map[gid.TenantID]error),
|
||||
})
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService {
|
||||
return GetTenantService(ctx, r.proboSvc, tenantID)
|
||||
}
|
||||
@@ -486,38 +439,20 @@ func UnwrapOmittable[T any](field graphql.Omittable[T]) *T {
|
||||
}
|
||||
|
||||
func GetTenantService(ctx context.Context, proboSvc *probo.Service, tenantID gid.TenantID) *probo.TenantService {
|
||||
validateTenantAccess(ctx, tenantID)
|
||||
serverauth.RequireTenantAccess(ctx, tenantID)
|
||||
return proboSvc.WithTenant(tenantID)
|
||||
}
|
||||
|
||||
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
|
||||
validateTenantAccess(ctx, tenantID)
|
||||
serverauth.RequireTenantAccess(ctx, tenantID)
|
||||
return authzSvc.WithTenant(tenantID)
|
||||
}
|
||||
|
||||
func GetTenantAuthService(ctx context.Context, authSvc *auth.Service, tenantID gid.TenantID) *auth.TenantAuthService {
|
||||
validateTenantAccess(ctx, tenantID)
|
||||
serverauth.RequireTenantAccess(ctx, tenantID)
|
||||
return authSvc.WithTenant(tenantID)
|
||||
}
|
||||
|
||||
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
|
||||
access, _ := ctx.Value(userTenantContextKey).(*userTenantAccess)
|
||||
|
||||
if access == nil {
|
||||
panic(&authz.TenantAccessError{Message: "tenant not found"})
|
||||
}
|
||||
|
||||
if !slices.Contains(access.tenantIDs, tenantID) {
|
||||
if access.authErrors != nil {
|
||||
if authErr := access.authErrors[tenantID]; authErr != nil {
|
||||
panic(authErr)
|
||||
}
|
||||
}
|
||||
|
||||
panic(&authz.TenantAccessError{Message: "tenant not found"})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action authz.Action) {
|
||||
user := UserFromContext(ctx)
|
||||
apiKey := UserAPIKeyFromContext(ctx)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ package console_v1
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.76
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.83
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/server/api/console/v1/schema"
|
||||
"go.probo.inc/probo/pkg/server/api/console/v1/types"
|
||||
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||
"go.probo.inc/probo/pkg/server/gqlutils"
|
||||
)
|
||||
|
||||
@@ -1451,9 +1452,9 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
|
||||
}
|
||||
|
||||
// Append tenant to allowed one
|
||||
access, _ := ctx.Value(userTenantContextKey).(*userTenantAccess)
|
||||
access := serverauth.UserTenantAccessFromContext(ctx)
|
||||
if access != nil {
|
||||
access.tenantIDs = append(access.tenantIDs, organization.ID.TenantID())
|
||||
access.TenantIDs = append(access.TenantIDs, organization.ID.TenantID())
|
||||
}
|
||||
|
||||
return &types.CreateOrganizationPayload{
|
||||
|
||||
76
pkg/server/api/mcp/mcputils/recovery.go
Normal file
76
pkg/server/api/mcp/mcputils/recovery.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package mcputils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/authz"
|
||||
)
|
||||
|
||||
// RecoveryMiddleware creates a middleware that recovers from panics in MCP method handlers.
|
||||
// It converts panics to errors, handling authz errors appropriately.
|
||||
func RecoveryMiddleware(logger *log.Logger) func(mcp.MethodHandler) mcp.MethodHandler {
|
||||
return func(next mcp.MethodHandler) mcp.MethodHandler {
|
||||
return func(ctx context.Context, method string, req mcp.Request) (result mcp.Result, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = convertPanicToError(ctx, logger, r)
|
||||
result = nil
|
||||
}
|
||||
}()
|
||||
|
||||
result, err = next(ctx, method, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// convertPanicToError converts a panic value to an error, handling authz errors appropriately.
|
||||
func convertPanicToError(ctx context.Context, logger *log.Logger, panicValue any) error {
|
||||
if panicValue == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle TenantAccessError - convert to "not authorized" error
|
||||
var tenantAccessErr *authz.TenantAccessError
|
||||
if errTyped, ok := panicValue.(error); ok && errors.As(errTyped, &tenantAccessErr) {
|
||||
return fmt.Errorf("not authorized: %s", tenantAccessErr.Message)
|
||||
}
|
||||
|
||||
// Handle PermissionDeniedError - convert to "permission denied" error
|
||||
var permissionDeniedErr *authz.PermissionDeniedError
|
||||
if errTyped, ok := panicValue.(error); ok && errors.As(errTyped, &permissionDeniedErr) {
|
||||
return fmt.Errorf("permission denied: %s", permissionDeniedErr.Message)
|
||||
}
|
||||
|
||||
// Handle other errors - return as-is
|
||||
if err, ok := panicValue.(error); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
// Log unexpected panics with stack trace
|
||||
logger.ErrorCtx(ctx, "unexpected panic in MCP method handler",
|
||||
log.Any("panic", panicValue),
|
||||
log.String("stack", string(debug.Stack())),
|
||||
)
|
||||
|
||||
return fmt.Errorf("internal server error")
|
||||
}
|
||||
@@ -16,12 +16,11 @@ package mcp_v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/auth"
|
||||
"go.probo.inc/probo/pkg/authz"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||
)
|
||||
|
||||
// WithMCPAuth wraps an HTTP handler with MCP authentication middleware
|
||||
@@ -44,71 +43,27 @@ func WithMCPAuth(
|
||||
log.String("path", r.URL.Path),
|
||||
)
|
||||
|
||||
// Extract API key from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
logger.WarnCtx(ctx, "MCP auth: missing Authorization header",
|
||||
// Authenticate using API key from shared function
|
||||
authCtx := serverauth.AuthenticateWithAPIKey(ctx, r, authSvc, authzSvc)
|
||||
if authCtx == nil {
|
||||
logger.WarnCtx(ctx, "MCP auth: authentication required",
|
||||
log.String("correlation_id", correlationID),
|
||||
)
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Expect "Bearer <token>" format
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
logger.WarnCtx(ctx, "MCP auth: invalid Authorization header format",
|
||||
log.String("correlation_id", correlationID),
|
||||
)
|
||||
http.Error(w, "invalid authorization header", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user := serverauth.UserFromContext(authCtx)
|
||||
userAPIKey := serverauth.UserAPIKeyFromContext(authCtx)
|
||||
tenantAccess := serverauth.UserTenantAccessFromContext(authCtx)
|
||||
|
||||
apiKeyToken := parts[1]
|
||||
|
||||
// Validate the API key
|
||||
user, userAPIKey, err := authSvc.ValidateUserAPIKey(ctx, apiKeyToken)
|
||||
if err != nil {
|
||||
logger.WarnCtx(ctx, "MCP auth: invalid API key",
|
||||
log.Error(err),
|
||||
log.String("correlation_id", correlationID),
|
||||
)
|
||||
http.Error(w, "invalid api key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Get organizations for this API key through the service layer
|
||||
organizations, err := authzSvc.GetAllOrganizationsForUserAPIKeyId(ctx, userAPIKey.ID)
|
||||
if err != nil {
|
||||
logger.ErrorCtx(ctx, "MCP auth: failed to load API key organizations",
|
||||
log.Error(err),
|
||||
log.String("correlation_id", correlationID),
|
||||
)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract tenant IDs from organizations
|
||||
tenantIDs := make([]gid.TenantID, 0, len(organizations))
|
||||
for _, org := range organizations {
|
||||
tenantIDs = append(tenantIDs, org.ID.TenantID())
|
||||
}
|
||||
|
||||
// Create MCP context with user and accessible tenants
|
||||
mcpCtx := &MCPContext{
|
||||
UserID: user.ID,
|
||||
TenantIDs: tenantIDs,
|
||||
}
|
||||
|
||||
ctx = ContextWithMCPContext(ctx, mcpCtx)
|
||||
|
||||
logger.InfoCtx(ctx, "MCP authentication successful",
|
||||
logger.InfoCtx(authCtx, "MCP authentication successful",
|
||||
log.String("correlation_id", correlationID),
|
||||
log.String("user_id", user.ID.String()),
|
||||
log.String("api_key_id", userAPIKey.ID.String()),
|
||||
log.Int("accessible_tenants", len(tenantIDs)),
|
||||
log.Int("accessible_tenants", len(tenantAccess.TenantIDs)),
|
||||
)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
next.ServeHTTP(w, r.WithContext(authCtx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
package mcp_v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.gearno.de/kit/log"
|
||||
"go.probo.inc/probo/pkg/auth"
|
||||
"go.probo.inc/probo/pkg/authz"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||
)
|
||||
|
||||
type Resolver struct {
|
||||
@@ -15,3 +19,26 @@ type Resolver struct {
|
||||
authzSvc *authz.Service
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func (r *Resolver) MustBeAuthorized(ctx context.Context, entityID gid.GID, action authz.Action) {
|
||||
user := serverauth.UserFromContext(ctx)
|
||||
apiKey := serverauth.UserAPIKeyFromContext(ctx)
|
||||
if user == nil {
|
||||
panic(&authz.TenantAccessError{Message: "authentication required"})
|
||||
}
|
||||
|
||||
authzSvc := r.AuthzService(ctx, entityID.TenantID())
|
||||
err := authzSvc.Authorize(ctx, user, apiKey, entityID, action)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Resolver) AuthzService(ctx context.Context, tenantID gid.TenantID) *authz.TenantAuthzService {
|
||||
return GetTenantAuthzService(ctx, r.authzSvc, tenantID)
|
||||
}
|
||||
|
||||
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
|
||||
serverauth.RequireTenantAccess(ctx, tenantID)
|
||||
return authzSvc.WithTenant(tenantID)
|
||||
}
|
||||
|
||||
@@ -9,17 +9,23 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"go.probo.inc/probo/pkg/authz"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/page"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/v1/types"
|
||||
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||
)
|
||||
|
||||
// ListOrganizationsTool handles the listOrganizations tool
|
||||
// List all organizations the user has access to
|
||||
func (r *Resolver) ListOrganizationsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListOrganizationsInput) (*mcp.CallToolResult, types.ListOrganizationsOutput, error) {
|
||||
mcpCtx := MCPContextFromContext(ctx)
|
||||
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, mcpCtx.UserID)
|
||||
user := serverauth.UserFromContext(ctx)
|
||||
if user == nil {
|
||||
return nil, types.ListOrganizationsOutput{}, fmt.Errorf("authentication required")
|
||||
}
|
||||
|
||||
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, types.ListOrganizationsOutput{}, fmt.Errorf("failed to list organizations: %w", err)
|
||||
}
|
||||
@@ -38,7 +44,9 @@ func (r *Resolver) ListOrganizationsTool(ctx context.Context, req *mcp.CallToolR
|
||||
// ListVendorsTool handles the listVendors tool
|
||||
// List all vendors for the organization
|
||||
func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListVendorsInput) (*mcp.CallToolResult, types.ListVendorsOutput, error) {
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListVendors)
|
||||
|
||||
prb := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.VendorOrderField]{
|
||||
Field: coredata.VendorOrderFieldCreatedAt,
|
||||
@@ -69,7 +77,9 @@ func (r *Resolver) ListVendorsTool(ctx context.Context, req *mcp.CallToolRequest
|
||||
// AddVendorTool handles the addVendor tool
|
||||
// Add a new vendor to the organization
|
||||
func (r *Resolver) AddVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddVendorInput) (*mcp.CallToolResult, types.AddVendorOutput, error) {
|
||||
svc := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreateAsset)
|
||||
|
||||
svc := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
vendor, err := svc.Vendors.Create(
|
||||
ctx,
|
||||
@@ -106,11 +116,14 @@ func (r *Resolver) AddVendorTool(ctx context.Context, req *mcp.CallToolRequest,
|
||||
// UpdateVendorTool handles the updateVendor tool
|
||||
// Update an existing vendor
|
||||
func (r *Resolver) UpdateVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateVendorInput) (*mcp.CallToolResult, types.UpdateVendorOutput, error) {
|
||||
|
||||
return nil, types.UpdateVendorOutput{}, fmt.Errorf("updateVendor not implemented")
|
||||
}
|
||||
|
||||
func (r *Resolver) ListPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.ListPeopleInput) (*mcp.CallToolResult, types.ListPeopleOutput, error) {
|
||||
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
|
||||
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionListPeople)
|
||||
|
||||
prb := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
pageOrderBy := page.OrderBy[coredata.PeopleOrderField]{
|
||||
Field: coredata.PeopleOrderFieldCreatedAt,
|
||||
@@ -139,7 +152,9 @@ func (r *Resolver) ListPeopleTool(ctx context.Context, req *mcp.CallToolRequest,
|
||||
}
|
||||
|
||||
func (r *Resolver) GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetPeopleInput) (*mcp.CallToolResult, types.GetPeopleOutput, error) {
|
||||
prb := r.ProboService(ctx, input.ID.TenantID())
|
||||
r.MustBeAuthorized(ctx, input.ID, authz.ActionGet)
|
||||
|
||||
prb := r.ProboService(ctx, input.ID)
|
||||
|
||||
people, err := prb.Peoples.Get(ctx, input.ID)
|
||||
if err != nil {
|
||||
@@ -150,3 +165,30 @@ func (r *Resolver) GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest,
|
||||
People: types.NewPeople(people),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) AddPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddPeopleInput) (*mcp.CallToolResult, types.AddPeopleOutput, error) {
|
||||
r.MustBeAuthorized(ctx, input.OrganizationID, authz.ActionCreatePeople)
|
||||
|
||||
svc := r.ProboService(ctx, input.OrganizationID)
|
||||
|
||||
people, err := svc.Peoples.Create(
|
||||
ctx,
|
||||
probo.CreatePeopleRequest{
|
||||
OrganizationID: input.OrganizationID,
|
||||
FullName: input.FullName,
|
||||
PrimaryEmailAddress: input.PrimaryEmailAddress,
|
||||
AdditionalEmailAddresses: input.AdditionalEmailAddresses,
|
||||
Kind: input.Kind,
|
||||
Position: input.Position,
|
||||
ContractStartDate: input.ContractStartDate,
|
||||
ContractEndDate: input.ContractEndDate,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, types.AddPeopleOutput{}, fmt.Errorf("failed to create people: %w", err)
|
||||
}
|
||||
|
||||
return nil, types.AddPeopleOutput{
|
||||
People: types.NewPeople(people),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type ResolverInterface interface {
|
||||
AddVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddVendorInput) (*mcp.CallToolResult, types.AddVendorOutput, error)
|
||||
UpdateVendorTool(ctx context.Context, req *mcp.CallToolRequest, input *types.UpdateVendorInput) (*mcp.CallToolResult, types.UpdateVendorOutput, error)
|
||||
GetPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.GetPeopleInput) (*mcp.CallToolResult, types.GetPeopleOutput, error)
|
||||
AddPeopleTool(ctx context.Context, req *mcp.CallToolRequest, input *types.AddPeopleInput) (*mcp.CallToolResult, types.AddPeopleOutput, error)
|
||||
}
|
||||
|
||||
// New creates a new MCP server instance with all handlers registered.
|
||||
@@ -95,4 +96,14 @@ func registerToolHandlers(server *mcp.Server, resolver ResolverInterface) {
|
||||
},
|
||||
resolver.GetPeopleTool,
|
||||
)
|
||||
mcp.AddTool(
|
||||
server,
|
||||
&mcp.Tool{
|
||||
Name: "addPeople",
|
||||
Description: "Add a new people to the organization",
|
||||
InputSchema: types.AddPeopleToolInputSchema,
|
||||
OutputSchema: types.AddPeopleToolOutputSchema,
|
||||
},
|
||||
resolver.AddPeopleTool,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -352,6 +352,51 @@ components:
|
||||
people:
|
||||
$ref: "#/components/schemas/People"
|
||||
|
||||
AddPeopleInput:
|
||||
type: object
|
||||
required:
|
||||
- organization_id
|
||||
- full_name
|
||||
- primary_email_address
|
||||
- kind
|
||||
properties:
|
||||
organization_id:
|
||||
$ref: "#/components/schemas/GID"
|
||||
description: Organization ID
|
||||
full_name:
|
||||
type: string
|
||||
description: Full name
|
||||
primary_email_address:
|
||||
type: string
|
||||
description: Primary email address
|
||||
additional_email_addresses:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Additional email addresses
|
||||
kind:
|
||||
$ref: "#/components/schemas/PeopleKind"
|
||||
description: People kind
|
||||
position:
|
||||
type: string
|
||||
description: Position
|
||||
contract_start_date:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Contract start date
|
||||
contract_end_date:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Contract end date
|
||||
|
||||
AddPeopleOutput:
|
||||
type: object
|
||||
required:
|
||||
- people
|
||||
properties:
|
||||
people:
|
||||
$ref: "#/components/schemas/People"
|
||||
|
||||
tools:
|
||||
- name: listOrganizations
|
||||
description: List all organizations the user has access to
|
||||
@@ -395,3 +440,10 @@ tools:
|
||||
$ref: "#/components/schemas/GetPeopleInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/GetPeopleOutput"
|
||||
- name: addPeople
|
||||
description: Add a new people to the organization
|
||||
readonly: false
|
||||
inputSchema:
|
||||
$ref: "#/components/schemas/AddPeopleInput"
|
||||
outputSchema:
|
||||
$ref: "#/components/schemas/AddPeopleOutput"
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
package mcp_v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -27,28 +24,8 @@ type (
|
||||
RequestTimeout time.Duration
|
||||
MaxRequestSize int64
|
||||
}
|
||||
|
||||
MCPContext struct {
|
||||
UserID gid.GID
|
||||
TenantIDs []gid.TenantID
|
||||
}
|
||||
|
||||
ctxKey struct{ name string }
|
||||
)
|
||||
|
||||
var (
|
||||
mcpContextKey = &ctxKey{name: "mcp_context"}
|
||||
)
|
||||
|
||||
func MCPContextFromContext(ctx context.Context) *MCPContext {
|
||||
mcpCtx, _ := ctx.Value(mcpContextKey).(*MCPContext)
|
||||
return mcpCtx
|
||||
}
|
||||
|
||||
func ContextWithMCPContext(ctx context.Context, mcpCtx *MCPContext) context.Context {
|
||||
return context.WithValue(ctx, mcpContextKey, mcpCtx)
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Version: "1.0.0",
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
|
||||
// Tool input schemas
|
||||
var (
|
||||
AddPeopleToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["organization_id","full_name","primary_email_address","kind"],"properties":{"additional_email_addresses":{"type":"array","description":"Additional email addresses","items":{"type":"string"}},"contract_end_date":{"type":"string","description":"Contract end date","format":"date-time"},"contract_start_date":{"type":"string","description":"Contract start date","format":"date-time"},"full_name":{"type":"string","description":"Full name"},"kind":{"type":"string","enum":["EMPLOYEE","CONTRACTOR","SERVICE_ACCOUNT"]},"organization_id":{"type":"string","format":"string"},"position":{"type":"string","description":"Position"},"primary_email_address":{"type":"string","description":"Primary email address"}}}`)
|
||||
AddPeopleToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["people"],"properties":{"people":{"type":"object","required":["id","organization_id","full_name","primary_email_address","additional_email_addresses","kind","created_at","updated_at"],"properties":{"additional_email_addresses":{"type":"array","description":"Additional email addresses","items":{"type":"string"}},"contract_end_date":{"description":"Contract end date","format":"date-time"},"contract_start_date":{"description":"Contract start date","format":"date-time"},"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"full_name":{"type":"string","description":"Full name"},"id":{"type":"string","format":"string"},"kind":{"type":"string","enum":["EMPLOYEE","CONTRACTOR","SERVICE_ACCOUNT"]},"organization_id":{"type":"string","format":"string"},"position":{"description":"Position"},"primary_email_address":{"type":"string","description":"Primary email address"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`)
|
||||
AddVendorToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["organization_id","name"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"type":"string","description":"Vendor description"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}`)
|
||||
AddVendorToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["vendor"],"properties":{"vendor":{"type":"object","required":["id","name","organization_id","created_at","updated_at"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"description":"Vendor description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`)
|
||||
GetPeopleToolInputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["id"],"properties":{"id":{"type":"string","format":"string"}}}`)
|
||||
@@ -26,6 +28,31 @@ var (
|
||||
UpdateVendorToolOutputSchema = mcp.MustUnmarshalSchema(`{"type":"object","required":["vendor"],"properties":{"vendor":{"type":"object","required":["id","name","organization_id","created_at","updated_at"],"properties":{"created_at":{"type":"string","description":"Creation timestamp","format":"date-time"},"description":{"description":"Vendor description"},"id":{"type":"string","format":"string"},"name":{"type":"string","description":"Vendor name"},"organization_id":{"type":"string","format":"string"},"updated_at":{"type":"string","description":"Update timestamp","format":"date-time"}}}}}`)
|
||||
)
|
||||
|
||||
// AddPeopleInput represents the schema
|
||||
type AddPeopleInput struct {
|
||||
// Additional email addresses
|
||||
AdditionalEmailAddresses []string `json:"additional_email_addresses,omitempty"`
|
||||
// Contract end date
|
||||
ContractEndDate *time.Time `json:"contract_end_date,omitempty"`
|
||||
// Contract start date
|
||||
ContractStartDate *time.Time `json:"contract_start_date,omitempty"`
|
||||
// Full name
|
||||
FullName string `json:"full_name"`
|
||||
// People kind
|
||||
Kind coredata.PeopleKind `json:"kind"`
|
||||
// Organization ID
|
||||
OrganizationID gid.GID `json:"organization_id"`
|
||||
// Position
|
||||
Position *string `json:"position,omitempty"`
|
||||
// Primary email address
|
||||
PrimaryEmailAddress string `json:"primary_email_address"`
|
||||
}
|
||||
|
||||
// AddPeopleOutput represents the schema
|
||||
type AddPeopleOutput struct {
|
||||
People *People `json:"people"`
|
||||
}
|
||||
|
||||
// AddVendorInput represents the schema
|
||||
type AddVendorInput struct {
|
||||
// Creation timestamp
|
||||
|
||||
@@ -2,7 +2,6 @@ package mcp_v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -13,27 +12,14 @@ import (
|
||||
"go.probo.inc/probo/pkg/authz"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/probo"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/mcputils"
|
||||
"go.probo.inc/probo/pkg/server/api/mcp/v1/server"
|
||||
serverauth "go.probo.inc/probo/pkg/server/auth"
|
||||
)
|
||||
|
||||
func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *probo.TenantService {
|
||||
validateTenantAccess(ctx, tenantID)
|
||||
return r.proboSvc.WithTenant(tenantID)
|
||||
}
|
||||
|
||||
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
|
||||
mcpCtx := MCPContextFromContext(ctx)
|
||||
if mcpCtx == nil {
|
||||
panic(fmt.Errorf("authentication context not found"))
|
||||
}
|
||||
|
||||
for _, tid := range mcpCtx.TenantIDs {
|
||||
if tid == tenantID {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
panic(fmt.Errorf("access denied: user does not have access to tenant %s", tenantID.String()))
|
||||
func (r *Resolver) ProboService(ctx context.Context, objectID gid.GID) *probo.TenantService {
|
||||
serverauth.RequireTenantAccess(ctx, objectID.TenantID())
|
||||
return r.proboSvc.WithTenant(objectID.TenantID())
|
||||
}
|
||||
|
||||
func NewMux(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, cfg Config) *chi.Mux {
|
||||
@@ -54,6 +40,10 @@ func NewMux(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service,
|
||||
|
||||
mcpServer := server.New(resolver)
|
||||
|
||||
// Add panic recovery middleware to handle panics in goroutines spawned by MCP SDK
|
||||
mcpServer.AddReceivingMiddleware(mcputils.LoggingMiddleware(logger))
|
||||
mcpServer.AddReceivingMiddleware(mcputils.RecoveryMiddleware(logger))
|
||||
|
||||
getServer := func(r *http.Request) *mcp.Server { return mcpServer }
|
||||
eventStore := mcp.NewMemoryEventStore(nil)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ package trust_v1
|
||||
|
||||
// This file will be automatically regenerated based on the schema, any resolver implementations
|
||||
// will be copied through when generating and any unknown code will be moved to the end.
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.76
|
||||
// Code generated by github.com/99designs/gqlgen version v0.17.83
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
Reference in New Issue
Block a user