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:
2026-05-08 11:27:47 +08:00
parent d0efe2bdd4
commit 7d9164d478
9 changed files with 1360 additions and 4 deletions
+224
View File
@@ -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
}