You've already forked agentic-coding-workflow
feat(mcp): 16 PM tools + chat tool + 6 resource URIs + admin token UI
Phase F: tools_pm.go — list/get/create/update/close/reopen/assign for projects, workspaces, requirements, issues (16 tools total). Phase G: tools_chat.go — list_recent_messages tool; resources.go — 6 PM resource URI templates (projects, project-detail, requirements, issues, workspaces, workspace-detail). Phase H: frontend admin UI — mcpTokens API client, Pinia store, MCPTokenAdminView with create/revoke/list, router entry, NavBar link. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerResources 注册 6 个 PM resource URI template。
|
||||||
|
// - pm://projects → static resource, lists all projects
|
||||||
|
// - pm://projects/{project_id} → template, single project
|
||||||
|
// - pm://projects/{project_id}/requirements → template, list requirements
|
||||||
|
// - pm://projects/{project_id}/issues → template, list issues
|
||||||
|
// - pm://projects/{project_id}/workspaces → template, list workspaces
|
||||||
|
// - pm://workspaces/{workspace_id} → template, single workspace
|
||||||
|
func registerResources(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
// Static resource: pm://projects
|
||||||
|
srv.AddResource(&mcpsdk.Resource{
|
||||||
|
URI: "pm://projects",
|
||||||
|
Name: "projects",
|
||||||
|
Description: "List all accessible projects",
|
||||||
|
}, listProjectsResource(deps))
|
||||||
|
|
||||||
|
// Template resources
|
||||||
|
srv.AddResourceTemplate(&mcpsdk.ResourceTemplate{
|
||||||
|
URITemplate: "pm://projects/{project_id}",
|
||||||
|
Name: "project-detail",
|
||||||
|
Description: "Get a single project by ID",
|
||||||
|
}, getProjectResource(deps))
|
||||||
|
|
||||||
|
srv.AddResourceTemplate(&mcpsdk.ResourceTemplate{
|
||||||
|
URITemplate: "pm://projects/{project_id}/requirements",
|
||||||
|
Name: "project-requirements",
|
||||||
|
Description: "List requirements in a project",
|
||||||
|
}, listRequirementsResource(deps))
|
||||||
|
|
||||||
|
srv.AddResourceTemplate(&mcpsdk.ResourceTemplate{
|
||||||
|
URITemplate: "pm://projects/{project_id}/issues",
|
||||||
|
Name: "project-issues",
|
||||||
|
Description: "List issues in a project",
|
||||||
|
}, listIssuesResource(deps))
|
||||||
|
|
||||||
|
srv.AddResourceTemplate(&mcpsdk.ResourceTemplate{
|
||||||
|
URITemplate: "pm://projects/{project_id}/workspaces",
|
||||||
|
Name: "project-workspaces",
|
||||||
|
Description: "List workspaces in a project",
|
||||||
|
}, listWorkspacesResource(deps))
|
||||||
|
|
||||||
|
srv.AddResourceTemplate(&mcpsdk.ResourceTemplate{
|
||||||
|
URITemplate: "pm://workspaces/{workspace_id}",
|
||||||
|
Name: "workspace-detail",
|
||||||
|
Description: "Get a single workspace by ID",
|
||||||
|
}, getWorkspaceResource(deps))
|
||||||
|
}
|
||||||
|
|
||||||
|
func listProjectsResource(deps ServerDeps) mcpsdk.ResourceHandler {
|
||||||
|
return func(ctx context.Context, req *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
|
||||||
|
caller, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
projects, err := deps.Projects.List(ctx, caller, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalResourceResult("pm://projects", projects)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProjectResource(deps ServerDeps) mcpsdk.ResourceHandler {
|
||||||
|
return func(ctx context.Context, req *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
|
||||||
|
caller, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pid, err := extractResourceUUID(req.Params.URI, "projects/")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, pid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.GetByID(ctx, caller, pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalResourceResult(req.Params.URI, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listRequirementsResource(deps ServerDeps) mcpsdk.ResourceHandler {
|
||||||
|
return func(ctx context.Context, req *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
|
||||||
|
caller, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pid, err := extractResourceUUID(req.Params.URI, "projects/")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, pid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.GetByID(ctx, caller, pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reqs, err := deps.Reqs.List(ctx, caller, p.Slug, project.RequirementFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalResourceResult(req.Params.URI, reqs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listIssuesResource(deps ServerDeps) mcpsdk.ResourceHandler {
|
||||||
|
return func(ctx context.Context, req *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
|
||||||
|
caller, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pid, err := extractResourceUUID(req.Params.URI, "projects/")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, pid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.GetByID(ctx, caller, pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
issues, err := deps.Issues.List(ctx, caller, p.Slug, project.IssueFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalResourceResult(req.Params.URI, issues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listWorkspacesResource(deps ServerDeps) mcpsdk.ResourceHandler {
|
||||||
|
return func(ctx context.Context, req *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
|
||||||
|
caller, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pid, err := extractResourceUUID(req.Params.URI, "projects/")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, pid); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.GetByID(ctx, caller, pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ws, err := deps.Workspaces.List(ctx, workspaceCallerFrom(caller), p.Slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalResourceResult(req.Params.URI, ws)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWorkspaceResource(deps ServerDeps) mcpsdk.ResourceHandler {
|
||||||
|
return func(ctx context.Context, req *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
|
||||||
|
caller, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wid, err := extractResourceUUID(req.Params.URI, "workspaces/")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w, err := deps.Workspaces.Get(ctx, workspaceCallerFrom(caller), wid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return marshalResourceResult(req.Params.URI, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspaceCallerFrom bridges project.Caller → workspace.Caller.
|
||||||
|
func workspaceCallerFrom(c project.Caller) workspace.Caller {
|
||||||
|
return workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractResourceUUID extracts a UUID from a pm:// URI like "pm://projects/{uuid}/requirements".
|
||||||
|
func extractResourceUUID(uri, prefix string) (uuid.UUID, error) {
|
||||||
|
s := uri
|
||||||
|
if strings.HasPrefix(s, "pm://") {
|
||||||
|
s = s[len("pm://"):]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(s, prefix) {
|
||||||
|
s = s[len(prefix):]
|
||||||
|
}
|
||||||
|
// Trim trailing path segments like "/requirements"
|
||||||
|
if idx := strings.Index(s, "/"); idx >= 0 {
|
||||||
|
s = s[:idx]
|
||||||
|
}
|
||||||
|
return uuid.Parse(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalResourceResult marshals v as JSON text content for resource responses.
|
||||||
|
func marshalResourceResult(uri string, v any) (*mcpsdk.ReadResourceResult, error) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &mcpsdk.ReadResourceResult{
|
||||||
|
Contents: []*mcpsdk.ResourceContents{{
|
||||||
|
URI: uri,
|
||||||
|
MIMEType: "application/json",
|
||||||
|
Text: string(data),
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -37,7 +37,6 @@ func NewMCPServer(deps ServerDeps) *mcpsdk.Server {
|
|||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
// register* 占位函数在后续 Phase F/G 实装时替换函数体。
|
// registerChatTools 已在 tools_chat.go 中实现。
|
||||||
func registerPMTools(_ *mcpsdk.Server, _ ServerDeps) {}
|
// registerResources 已在 resources.go 中实现。
|
||||||
func registerChatTools(_ *mcpsdk.Server, _ ServerDeps) {}
|
// registerPMTools 已在 tools_pm.go 中实现。
|
||||||
func registerResources(_ *mcpsdk.Server, _ ServerDeps) {}
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerChatTools 注册 chat 相关 MCP 工具。
|
||||||
|
// MVP 只有一个:list_recent_messages。
|
||||||
|
func registerChatTools(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
registerListRecentMessages(srv, deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
type listRecentMessagesIn struct {
|
||||||
|
ConversationID string `json:"conversation_id" jsonschema:"required,description=UUID of the conversation"`
|
||||||
|
BeforeID *int64 `json:"before_id,omitempty" jsonschema:"description=Cursor: return messages with ID < this value"`
|
||||||
|
Limit int `json:"limit,omitempty" jsonschema:"description=Max messages to return (default 50, max 200)"`
|
||||||
|
}
|
||||||
|
type messageSummary struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
type listRecentMessagesOut struct {
|
||||||
|
Messages []messageSummary `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerListRecentMessages(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "list_recent_messages", Description: "List recent chat messages in a conversation."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRecentMessagesIn) (*mcpsdk.CallToolResult, listRecentMessagesOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "list_recent_messages"); err != nil {
|
||||||
|
return nil, listRecentMessagesOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listRecentMessagesOut{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
convID, err := uuid.Parse(in.ConversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listRecentMessagesOut{}, fmt.Errorf("invalid conversation_id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := in.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err := deps.Messages.ListMessages(ctx, c.UserID, convID, in.BeforeID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listRecentMessagesOut{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := listRecentMessagesOut{Messages: make([]messageSummary, 0, len(msgs))}
|
||||||
|
for _, m := range msgs {
|
||||||
|
out.Messages = append(out.Messages, messageSummary{
|
||||||
|
ID: m.ID,
|
||||||
|
Role: string(m.Role),
|
||||||
|
Content: m.Content,
|
||||||
|
Status: string(m.Status),
|
||||||
|
CreatedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, out, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
registerListProjects(srv, deps)
|
||||||
|
registerListWorkspaces(srv, deps)
|
||||||
|
registerListRequirements(srv, deps)
|
||||||
|
registerListIssues(srv, deps)
|
||||||
|
registerGetIssue(srv, deps)
|
||||||
|
registerGetRequirement(srv, deps)
|
||||||
|
registerCreateIssue(srv, deps)
|
||||||
|
registerUpdateIssue(srv, deps)
|
||||||
|
registerCloseIssue(srv, deps)
|
||||||
|
registerReopenIssue(srv, deps)
|
||||||
|
registerAssignIssue(srv, deps)
|
||||||
|
registerCreateRequirement(srv, deps)
|
||||||
|
registerUpdateRequirement(srv, deps)
|
||||||
|
registerCloseRequirement(srv, deps)
|
||||||
|
registerReopenRequirement(srv, deps)
|
||||||
|
registerSetRequirementPhase(srv, deps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== list_projects =====
|
||||||
|
|
||||||
|
type listProjectsIn struct {
|
||||||
|
Archived bool `json:"archived,omitempty" jsonschema:"include archived projects"`
|
||||||
|
}
|
||||||
|
type projectSummary struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
type listProjectsOut struct {
|
||||||
|
Projects []projectSummary `json:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerListProjects(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "list_projects", Description: "List projects visible to the caller."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listProjectsIn) (*mcpsdk.CallToolResult, listProjectsOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "list_projects"); err != nil {
|
||||||
|
return nil, listProjectsOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listProjectsOut{}, err
|
||||||
|
}
|
||||||
|
ps, err := deps.Projects.List(ctx, c, in.Archived)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listProjectsOut{}, err
|
||||||
|
}
|
||||||
|
out := listProjectsOut{Projects: make([]projectSummary, 0, len(ps))}
|
||||||
|
for _, p := range ps {
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Projects = append(out.Projects, projectSummary{
|
||||||
|
Slug: p.Slug, Name: p.Name, Visibility: string(p.Visibility),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, out, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== list_workspaces =====
|
||||||
|
|
||||||
|
type listWorkspacesIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required,project slug"`
|
||||||
|
}
|
||||||
|
type workspaceSummary struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DefaultBranch string `json:"default_branch"`
|
||||||
|
SyncStatus string `json:"sync_status"`
|
||||||
|
}
|
||||||
|
type listWorkspacesOut struct {
|
||||||
|
Workspaces []workspaceSummary `json:"workspaces"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerListWorkspaces(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "list_workspaces", Description: "List workspaces in a project."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listWorkspacesIn) (*mcpsdk.CallToolResult, listWorkspacesOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "list_workspaces"); err != nil {
|
||||||
|
return nil, listWorkspacesOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listWorkspacesOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listWorkspacesOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, listWorkspacesOut{}, err
|
||||||
|
}
|
||||||
|
wsCaller := workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}
|
||||||
|
ws, err := deps.Workspaces.List(ctx, wsCaller, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listWorkspacesOut{}, err
|
||||||
|
}
|
||||||
|
out := listWorkspacesOut{Workspaces: make([]workspaceSummary, 0, len(ws))}
|
||||||
|
for _, w := range ws {
|
||||||
|
out.Workspaces = append(out.Workspaces, workspaceSummary{
|
||||||
|
ID: w.ID.String(), Slug: w.Slug, Name: w.Name,
|
||||||
|
DefaultBranch: w.DefaultBranch, SyncStatus: string(w.SyncStatus),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, out, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== list_requirements =====
|
||||||
|
|
||||||
|
type listRequirementsIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Phase string `json:"phase,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
type requirementSummary struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
type listRequirementsOut struct {
|
||||||
|
Requirements []requirementSummary `json:"requirements"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerListRequirements(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "list_requirements", Description: "List requirements in a project."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listRequirementsIn) (*mcpsdk.CallToolResult, listRequirementsOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "list_requirements"); err != nil {
|
||||||
|
return nil, listRequirementsOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listRequirementsOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listRequirementsOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, listRequirementsOut{}, err
|
||||||
|
}
|
||||||
|
rs, err := deps.Reqs.List(ctx, c, in.ProjectSlug, project.RequirementFilter{
|
||||||
|
Phase: project.Phase(in.Phase), Status: project.Status(in.Status),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, listRequirementsOut{}, err
|
||||||
|
}
|
||||||
|
out := listRequirementsOut{Requirements: make([]requirementSummary, 0, len(rs))}
|
||||||
|
for _, r := range rs {
|
||||||
|
out.Requirements = append(out.Requirements, requirementSummary{
|
||||||
|
Number: r.Number, Title: r.Title,
|
||||||
|
Phase: string(r.Phase), Status: string(r.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, out, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== list_issues =====
|
||||||
|
|
||||||
|
type listIssuesIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
RequirementNumber any `json:"requirement_number,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
AssigneeID string `json:"assignee_id,omitempty"`
|
||||||
|
}
|
||||||
|
type issueSummary struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
type listIssuesOut struct {
|
||||||
|
Issues []issueSummary `json:"issues"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerListIssues(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "list_issues", Description: "List issues in a project."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listIssuesIn) (*mcpsdk.CallToolResult, listIssuesOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "list_issues"); err != nil {
|
||||||
|
return nil, listIssuesOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listIssuesOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listIssuesOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, listIssuesOut{}, err
|
||||||
|
}
|
||||||
|
f := project.IssueFilter{Status: project.Status(in.Status)}
|
||||||
|
switch v := in.RequirementNumber.(type) {
|
||||||
|
case nil:
|
||||||
|
case string:
|
||||||
|
if v == "none" {
|
||||||
|
zero := 0
|
||||||
|
f.Requirement = &zero
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
n := int(v)
|
||||||
|
f.Requirement = &n
|
||||||
|
}
|
||||||
|
if in.AssigneeID != "" {
|
||||||
|
aid, err := uuid.Parse(in.AssigneeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listIssuesOut{}, errs.Wrap(err, errs.CodeInvalidInput, "assignee_id parse")
|
||||||
|
}
|
||||||
|
f.AssigneeID = &aid
|
||||||
|
}
|
||||||
|
is, err := deps.Issues.List(ctx, c, in.ProjectSlug, f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, listIssuesOut{}, err
|
||||||
|
}
|
||||||
|
out := listIssuesOut{Issues: make([]issueSummary, 0, len(is))}
|
||||||
|
for _, i := range is {
|
||||||
|
out.Issues = append(out.Issues, issueSummary{
|
||||||
|
Number: i.Number, Title: i.Title, Status: string(i.Status),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil, out, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== get_issue =====
|
||||||
|
|
||||||
|
type getIssueIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Number int `json:"number" jsonschema:"required,min=1"`
|
||||||
|
}
|
||||||
|
type issueDetail struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerGetIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "get_issue", Description: "Get a single issue by project_slug + number."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getIssueIn) (*mcpsdk.CallToolResult, issueDetail, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "get_issue"); err != nil {
|
||||||
|
return nil, issueDetail{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueDetail{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueDetail{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, issueDetail{}, err
|
||||||
|
}
|
||||||
|
i, err := deps.Issues.Get(ctx, c, in.ProjectSlug, in.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueDetail{}, err
|
||||||
|
}
|
||||||
|
return nil, issueDetail{
|
||||||
|
Number: i.Number, Title: i.Title, Description: i.Description,
|
||||||
|
Status: string(i.Status), CreatedAt: i.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== get_requirement =====
|
||||||
|
|
||||||
|
type getRequirementIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Number int `json:"number" jsonschema:"required,min=1"`
|
||||||
|
}
|
||||||
|
type requirementDetail struct {
|
||||||
|
Number int `json:"number"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerGetRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "get_requirement", Description: "Get a single requirement by project_slug + number."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRequirementIn) (*mcpsdk.CallToolResult, requirementDetail, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "get_requirement"); err != nil {
|
||||||
|
return nil, requirementDetail{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementDetail{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementDetail{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, requirementDetail{}, err
|
||||||
|
}
|
||||||
|
r, err := deps.Reqs.Get(ctx, c, in.ProjectSlug, in.Number)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementDetail{}, err
|
||||||
|
}
|
||||||
|
return nil, requirementDetail{
|
||||||
|
Number: r.Number, Title: r.Title, Description: r.Description,
|
||||||
|
Phase: string(r.Phase), Status: string(r.Status),
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== create_issue =====
|
||||||
|
|
||||||
|
type createIssueIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Title string `json:"title" jsonschema:"required,minLength=1"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
type issueCreateOut struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Issue issueDetail `json:"issue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCreateIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "create_issue", Description: "Create a new issue in a project."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createIssueIn) (*mcpsdk.CallToolResult, issueCreateOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "create_issue"); err != nil {
|
||||||
|
return nil, issueCreateOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueCreateOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueCreateOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, issueCreateOut{}, err
|
||||||
|
}
|
||||||
|
i, err := deps.Issues.Create(ctx, c, in.ProjectSlug, project.CreateIssueInput{
|
||||||
|
Title: in.Title, Description: in.Description,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueCreateOut{}, err
|
||||||
|
}
|
||||||
|
return nil, issueCreateOut{OK: true, Issue: issueDetail{
|
||||||
|
Number: i.Number, Title: i.Title, Description: i.Description,
|
||||||
|
Status: string(i.Status), CreatedAt: i.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== update_issue =====
|
||||||
|
|
||||||
|
type updateIssueIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Number int `json:"number" jsonschema:"required,min=1"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
type issueUpdateOut struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Issue issueDetail `json:"issue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerUpdateIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "update_issue", Description: "Update an existing issue."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateIssueIn) (*mcpsdk.CallToolResult, issueUpdateOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "update_issue"); err != nil {
|
||||||
|
return nil, issueUpdateOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueUpdateOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueUpdateOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, issueUpdateOut{}, err
|
||||||
|
}
|
||||||
|
input := project.UpdateIssueInput{}
|
||||||
|
if in.Title != "" {
|
||||||
|
input.Title = &in.Title
|
||||||
|
}
|
||||||
|
if in.Description != "" {
|
||||||
|
input.Description = &in.Description
|
||||||
|
}
|
||||||
|
i, err := deps.Issues.Update(ctx, c, in.ProjectSlug, in.Number, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, issueUpdateOut{}, err
|
||||||
|
}
|
||||||
|
return nil, issueUpdateOut{OK: true, Issue: issueDetail{
|
||||||
|
Number: i.Number, Title: i.Title, Description: i.Description,
|
||||||
|
Status: string(i.Status), CreatedAt: i.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== close/reopen_issue =====
|
||||||
|
|
||||||
|
type closeIssueIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Number int `json:"number" jsonschema:"required,min=1"`
|
||||||
|
}
|
||||||
|
type okOut struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCloseIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "close_issue", Description: "Close an issue (idempotent)."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in closeIssueIn) (*mcpsdk.CallToolResult, okOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "close_issue"); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := deps.Issues.Close(ctx, c, in.ProjectSlug, in.Number); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
return nil, okOut{OK: true}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerReopenIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "reopen_issue", Description: "Reopen a closed issue (idempotent)."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in closeIssueIn) (*mcpsdk.CallToolResult, okOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "reopen_issue"); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := deps.Issues.Reopen(ctx, c, in.ProjectSlug, in.Number); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
return nil, okOut{OK: true}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== assign_issue =====
|
||||||
|
|
||||||
|
type assignIssueIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Number int `json:"number" jsonschema:"required,min=1"`
|
||||||
|
AssigneeID *string `json:"assignee_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAssignIssue(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "assign_issue", Description: "Assign or unassign an issue."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in assignIssueIn) (*mcpsdk.CallToolResult, okOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "assign_issue"); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
var assignee *uuid.UUID
|
||||||
|
if in.AssigneeID != nil && *in.AssigneeID != "" {
|
||||||
|
aid, err := uuid.Parse(*in.AssigneeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, errs.Wrap(err, errs.CodeInvalidInput, "assignee_id parse")
|
||||||
|
}
|
||||||
|
assignee = &aid
|
||||||
|
}
|
||||||
|
if _, err := deps.Issues.Assign(ctx, c, in.ProjectSlug, in.Number, assignee); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
return nil, okOut{OK: true}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== create_requirement =====
|
||||||
|
|
||||||
|
type createRequirementIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Title string `json:"title" jsonschema:"required,minLength=1"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
type requirementCreateOut struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Requirement requirementDetail `json:"requirement"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCreateRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "create_requirement", Description: "Create a new requirement."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createRequirementIn) (*mcpsdk.CallToolResult, requirementCreateOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "create_requirement"); err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
r, err := deps.Reqs.Create(ctx, c, in.ProjectSlug, project.CreateRequirementInput{
|
||||||
|
Title: in.Title, Description: in.Description,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
return nil, requirementCreateOut{OK: true, Requirement: requirementDetail{
|
||||||
|
Number: r.Number, Title: r.Title, Description: r.Description,
|
||||||
|
Phase: string(r.Phase), Status: string(r.Status),
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== update_requirement =====
|
||||||
|
|
||||||
|
type updateRequirementIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Number int `json:"number" jsonschema:"required,min=1"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerUpdateRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "update_requirement", Description: "Update an existing requirement."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in updateRequirementIn) (*mcpsdk.CallToolResult, requirementCreateOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "update_requirement"); err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
input := project.UpdateRequirementInput{}
|
||||||
|
if in.Title != "" {
|
||||||
|
input.Title = &in.Title
|
||||||
|
}
|
||||||
|
if in.Description != "" {
|
||||||
|
input.Description = &in.Description
|
||||||
|
}
|
||||||
|
r, err := deps.Reqs.Update(ctx, c, in.ProjectSlug, in.Number, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
return nil, requirementCreateOut{OK: true, Requirement: requirementDetail{
|
||||||
|
Number: r.Number, Title: r.Title, Description: r.Description,
|
||||||
|
Phase: string(r.Phase), Status: string(r.Status),
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== close/reopen_requirement =====
|
||||||
|
|
||||||
|
type closeRequirementIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Number int `json:"number" jsonschema:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerCloseRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "close_requirement", Description: "Close a requirement (idempotent)."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in closeRequirementIn) (*mcpsdk.CallToolResult, okOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "close_requirement"); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := deps.Reqs.Close(ctx, c, in.ProjectSlug, in.Number); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
return nil, okOut{OK: true}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerReopenRequirement(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "reopen_requirement", Description: "Reopen a closed requirement (idempotent)."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in closeRequirementIn) (*mcpsdk.CallToolResult, okOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "reopen_requirement"); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
if err := deps.Reqs.Reopen(ctx, c, in.ProjectSlug, in.Number); err != nil {
|
||||||
|
return nil, okOut{}, err
|
||||||
|
}
|
||||||
|
return nil, okOut{OK: true}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== set_requirement_phase =====
|
||||||
|
|
||||||
|
type setRequirementPhaseIn struct {
|
||||||
|
ProjectSlug string `json:"project_slug" jsonschema:"required"`
|
||||||
|
Number int `json:"number" jsonschema:"required,min=1"`
|
||||||
|
Phase string `json:"phase" jsonschema:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) {
|
||||||
|
mcpsdk.AddTool(srv,
|
||||||
|
&mcpsdk.Tool{Name: "set_requirement_phase", Description: "Change a requirement's phase."},
|
||||||
|
func(ctx context.Context, _ *mcpsdk.CallToolRequest, in setRequirementPhaseIn) (*mcpsdk.CallToolResult, requirementCreateOut, error) {
|
||||||
|
if err := CheckToolFromCtx(ctx, "set_requirement_phase"); err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
c, err := deps.Caller.Resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
p, err := deps.Projects.Get(ctx, c, in.ProjectSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
if err := CheckProjectFromCtx(ctx, p.ID); err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
r, err := deps.Reqs.ChangePhase(ctx, c, in.ProjectSlug, in.Number, project.Phase(in.Phase))
|
||||||
|
if err != nil {
|
||||||
|
return nil, requirementCreateOut{}, err
|
||||||
|
}
|
||||||
|
return nil, requirementCreateOut{OK: true, Requirement: requirementDetail{
|
||||||
|
Number: r.Number, Title: r.Title, Description: r.Description,
|
||||||
|
Phase: string(r.Phase), Status: string(r.Status),
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保证 fmt 引用(编译期检查)
|
||||||
|
var _ = fmt.Sprintf
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { buildQuery, request } from './client'
|
||||||
|
|
||||||
|
export interface MCPTokenScope {
|
||||||
|
tools?: string[] | null
|
||||||
|
project_ids?: string[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MCPToken {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
issuer: 'admin' | 'system'
|
||||||
|
scope: MCPTokenScope
|
||||||
|
expires_at?: string | null
|
||||||
|
last_used_at?: string | null
|
||||||
|
revoked_at?: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTokenReq {
|
||||||
|
user_id?: string
|
||||||
|
name: string
|
||||||
|
scope?: MCPTokenScope
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTokenResp {
|
||||||
|
id: string
|
||||||
|
token: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mcpTokensApi = {
|
||||||
|
list: (params?: { active_only?: boolean }) =>
|
||||||
|
request<MCPToken[]>(
|
||||||
|
`/api/v1/mcp/tokens${buildQuery(params ?? {})}`,
|
||||||
|
),
|
||||||
|
|
||||||
|
create: (req: CreateTokenReq) =>
|
||||||
|
request<CreateTokenResp>('/api/v1/mcp/tokens', { method: 'POST', body: req }),
|
||||||
|
|
||||||
|
revoke: (id: string) =>
|
||||||
|
request<void>(`/api/v1/mcp/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
@@ -57,6 +57,12 @@
|
|||||||
>
|
>
|
||||||
全局用量
|
全局用量
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'admin-mcp-tokens' }"
|
||||||
|
class="text-sm hover:underline"
|
||||||
|
>
|
||||||
|
MCP Tokens
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
props: { adminMode: true },
|
props: { adminMode: true },
|
||||||
meta: { requiresAuth: true, requiresAdmin: true },
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/mcp-tokens',
|
||||||
|
name: 'admin-mcp-tokens',
|
||||||
|
component: () => import('@/views/admin/MCPTokenAdminView.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/acp/agent-kinds',
|
path: '/admin/acp/agent-kinds',
|
||||||
component: () => import('@/views/admin/AgentKindAdminListView.vue'),
|
component: () => import('@/views/admin/AgentKindAdminListView.vue'),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import {
|
||||||
|
mcpTokensApi,
|
||||||
|
type MCPToken,
|
||||||
|
type CreateTokenReq,
|
||||||
|
type CreateTokenResp,
|
||||||
|
} from '@/api/mcpTokens'
|
||||||
|
|
||||||
|
export const useMcpTokensStore = defineStore('mcpTokens', () => {
|
||||||
|
const tokens = ref<MCPToken[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetch(params?: { active_only?: boolean }) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
tokens.value = await mcpTokensApi.list(params)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(req: CreateTokenReq): Promise<CreateTokenResp> {
|
||||||
|
const resp = await mcpTokensApi.create(req)
|
||||||
|
await fetch()
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revoke(id: string) {
|
||||||
|
await mcpTokensApi.revoke(id)
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tokens, loading, fetch, create, revoke }
|
||||||
|
})
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
<template>
|
||||||
|
<AppShell>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-semibold">
|
||||||
|
MCP Tokens
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NCheckbox
|
||||||
|
v-model:checked="activeOnly"
|
||||||
|
@update:checked="reload"
|
||||||
|
>
|
||||||
|
仅显示活跃
|
||||||
|
</NCheckbox>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NDataTable
|
||||||
|
:data="store.tokens"
|
||||||
|
:columns="columns"
|
||||||
|
:row-key="rowKey"
|
||||||
|
:loading="store.loading"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Create modal -->
|
||||||
|
<NModal
|
||||||
|
v-model:show="showCreate"
|
||||||
|
preset="card"
|
||||||
|
title="新增 MCP Token"
|
||||||
|
style="width: 520px"
|
||||||
|
>
|
||||||
|
<NForm>
|
||||||
|
<NFormItem label="名称">
|
||||||
|
<NInput v-model:value="createForm.name" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="过期时间">
|
||||||
|
<NDatePicker
|
||||||
|
v-model:value="createForm.expiresAt"
|
||||||
|
type="datetime"
|
||||||
|
:is-date-disabled="isDateDisabled"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="允许的工具 (scope)">
|
||||||
|
<NInput
|
||||||
|
v-model:value="toolsInput"
|
||||||
|
placeholder="逗号分隔,留空表示全部"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NButton @click="showCreate = false">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="creating"
|
||||||
|
:disabled="!canCreate"
|
||||||
|
@click="doCreate"
|
||||||
|
>
|
||||||
|
创建
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
|
||||||
|
<!-- Token display dialog -->
|
||||||
|
<NModal
|
||||||
|
v-model:show="showToken"
|
||||||
|
preset="card"
|
||||||
|
title="Token 已创建"
|
||||||
|
style="width: 520px"
|
||||||
|
>
|
||||||
|
<NAlert
|
||||||
|
type="warning"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
请立即复制 Token,关闭后无法再次查看。
|
||||||
|
</NAlert>
|
||||||
|
<NInput
|
||||||
|
:value="createdToken"
|
||||||
|
readonly
|
||||||
|
type="textarea"
|
||||||
|
rows="3"
|
||||||
|
class="mt-3"
|
||||||
|
/>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<NButton @click="copyToken">
|
||||||
|
复制
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
NAlert,
|
||||||
|
NButton,
|
||||||
|
NCheckbox,
|
||||||
|
NDataTable,
|
||||||
|
NDatePicker,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NModal,
|
||||||
|
useDialog,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
|
|
||||||
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
|
import { useMcpTokensStore } from '@/stores/mcpTokens'
|
||||||
|
import type { MCPToken } from '@/api/mcpTokens'
|
||||||
|
|
||||||
|
const store = useMcpTokensStore()
|
||||||
|
const msg = useMessage()
|
||||||
|
const dlg = useDialog()
|
||||||
|
|
||||||
|
const activeOnly = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const showToken = ref(false)
|
||||||
|
const createdToken = ref('')
|
||||||
|
|
||||||
|
const createForm = ref<{ name: string; expiresAt: number | null }>({
|
||||||
|
name: '',
|
||||||
|
expiresAt: null,
|
||||||
|
})
|
||||||
|
const toolsInput = ref('')
|
||||||
|
|
||||||
|
const canCreate = computed(() => {
|
||||||
|
const f = createForm.value
|
||||||
|
return !!f.name.trim() && f.expiresAt !== null
|
||||||
|
})
|
||||||
|
|
||||||
|
function isDateDisabled(ts: number): boolean {
|
||||||
|
return ts < Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumns<MCPToken> = [
|
||||||
|
{ title: '名称', key: 'name' },
|
||||||
|
{ title: '签发者', key: 'issuer', width: 90 },
|
||||||
|
{
|
||||||
|
title: '过期时间',
|
||||||
|
key: 'expires_at',
|
||||||
|
render: (row) => row.expires_at ? new Date(row.expires_at).toLocaleString() : '永不过期',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后使用',
|
||||||
|
key: 'last_used_at',
|
||||||
|
render: (row) => row.last_used_at ? new Date(row.last_used_at).toLocaleString() : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'status',
|
||||||
|
render: (row) => {
|
||||||
|
if (row.revoked_at) return '已吊销'
|
||||||
|
if (row.expires_at && new Date(row.expires_at) < new Date()) return '已过期'
|
||||||
|
return '活跃'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
key: 'created_at',
|
||||||
|
render: (row) => new Date(row.created_at).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: 'small',
|
||||||
|
type: 'error',
|
||||||
|
disabled: !!row.revoked_at,
|
||||||
|
onClick: () => revoke(row),
|
||||||
|
},
|
||||||
|
{ default: () => '吊销' },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const rowKey = (row: MCPToken) => row.id
|
||||||
|
|
||||||
|
onMounted(reload)
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
await store.fetch(activeOnly.value ? { active_only: true } : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
createForm.value = { name: '', expiresAt: null }
|
||||||
|
toolsInput.value = ''
|
||||||
|
showCreate.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doCreate() {
|
||||||
|
const f = createForm.value
|
||||||
|
if (!f.name.trim() || f.expiresAt === null) return
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const tools = toolsInput.value.trim()
|
||||||
|
? toolsInput.value.split(',').map((s) => s.trim()).filter(Boolean)
|
||||||
|
: undefined
|
||||||
|
const resp = await store.create({
|
||||||
|
name: f.name.trim(),
|
||||||
|
expires_at: new Date(f.expiresAt).toISOString(),
|
||||||
|
scope: tools?.length ? { tools } : undefined,
|
||||||
|
})
|
||||||
|
showCreate.value = false
|
||||||
|
createdToken.value = resp.token
|
||||||
|
showToken.value = true
|
||||||
|
} catch (e: unknown) {
|
||||||
|
msg.error((e as Error).message || '创建失败')
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revoke(row: MCPToken) {
|
||||||
|
dlg.warning({
|
||||||
|
title: '确认吊销',
|
||||||
|
content: `确定要吊销 Token "${row.name}" 吗?此操作不可撤销。`,
|
||||||
|
positiveText: '吊销',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await store.revoke(row.id)
|
||||||
|
msg.success('已吊销')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
msg.error((e as Error).message || '吊销失败')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToken() {
|
||||||
|
navigator.clipboard.writeText(createdToken.value).then(
|
||||||
|
() => msg.success('已复制'),
|
||||||
|
() => msg.error('复制失败'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user