From 7d9164d478e77679d7cecfe05c58a7a149bdeefe Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Fri, 8 May 2026 11:27:47 +0800 Subject: [PATCH] feat(mcp): 16 PM tools + chat tool + 6 resource URIs + admin token UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/mcp/resources.go | 224 +++++++ internal/mcp/server.go | 7 +- internal/mcp/tools_chat.go | 75 +++ internal/mcp/tools_pm.go | 713 ++++++++++++++++++++++ web/src/api/mcpTokens.ts | 43 ++ web/src/components/layout/NavBar.vue | 6 + web/src/router/index.ts | 6 + web/src/stores/mcpTokens.ts | 35 ++ web/src/views/admin/MCPTokenAdminView.vue | 255 ++++++++ 9 files changed, 1360 insertions(+), 4 deletions(-) create mode 100644 internal/mcp/resources.go create mode 100644 internal/mcp/tools_chat.go create mode 100644 internal/mcp/tools_pm.go create mode 100644 web/src/api/mcpTokens.ts create mode 100644 web/src/stores/mcpTokens.ts create mode 100644 web/src/views/admin/MCPTokenAdminView.vue diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go new file mode 100644 index 0000000..c02c62a --- /dev/null +++ b/internal/mcp/resources.go @@ -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 +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index f8626a5..3532538 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -37,7 +37,6 @@ func NewMCPServer(deps ServerDeps) *mcpsdk.Server { return srv } -// register* 占位函数在后续 Phase F/G 实装时替换函数体。 -func registerPMTools(_ *mcpsdk.Server, _ ServerDeps) {} -func registerChatTools(_ *mcpsdk.Server, _ ServerDeps) {} -func registerResources(_ *mcpsdk.Server, _ ServerDeps) {} +// registerChatTools 已在 tools_chat.go 中实现。 +// registerResources 已在 resources.go 中实现。 +// registerPMTools 已在 tools_pm.go 中实现。 diff --git a/internal/mcp/tools_chat.go b/internal/mcp/tools_chat.go new file mode 100644 index 0000000..2a71961 --- /dev/null +++ b/internal/mcp/tools_chat.go @@ -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 + }) +} diff --git a/internal/mcp/tools_pm.go b/internal/mcp/tools_pm.go new file mode 100644 index 0000000..c767fae --- /dev/null +++ b/internal/mcp/tools_pm.go @@ -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 diff --git a/web/src/api/mcpTokens.ts b/web/src/api/mcpTokens.ts new file mode 100644 index 0000000..e0bfbef --- /dev/null +++ b/web/src/api/mcpTokens.ts @@ -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( + `/api/v1/mcp/tokens${buildQuery(params ?? {})}`, + ), + + create: (req: CreateTokenReq) => + request('/api/v1/mcp/tokens', { method: 'POST', body: req }), + + revoke: (id: string) => + request(`/api/v1/mcp/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' }), +} diff --git a/web/src/components/layout/NavBar.vue b/web/src/components/layout/NavBar.vue index 6aa4c6e..0bce1fb 100644 --- a/web/src/components/layout/NavBar.vue +++ b/web/src/components/layout/NavBar.vue @@ -57,6 +57,12 @@ > 全局用量 + + MCP Tokens + import('@/views/admin/MCPTokenAdminView.vue'), + meta: { requiresAuth: true, requiresAdmin: true }, + }, { path: '/admin/acp/agent-kinds', component: () => import('@/views/admin/AgentKindAdminListView.vue'), diff --git a/web/src/stores/mcpTokens.ts b/web/src/stores/mcpTokens.ts new file mode 100644 index 0000000..246a0c0 --- /dev/null +++ b/web/src/stores/mcpTokens.ts @@ -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([]) + 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 { + 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 } +}) diff --git a/web/src/views/admin/MCPTokenAdminView.vue b/web/src/views/admin/MCPTokenAdminView.vue new file mode 100644 index 0000000..f69182d --- /dev/null +++ b/web/src/views/admin/MCPTokenAdminView.vue @@ -0,0 +1,255 @@ + + +