From 7d3d67cce33945624eb898503afd0c6eb75ac8d5 Mon Sep 17 00:00:00 2001 From: Paul Griffin Date: Sun, 15 Feb 2026 21:25:08 +0000 Subject: [PATCH] Add MCP timelog app UI with project/task selectors --- internal/twprojects/apps/timelog_create.html | 872 +++++++++++++++++++ internal/twprojects/timelog_app.go | 44 + internal/twprojects/timelog_app_test.go | 93 ++ internal/twprojects/timelogs.go | 5 + internal/twprojects/tools.go | 17 +- 5 files changed, 1026 insertions(+), 5 deletions(-) create mode 100644 internal/twprojects/apps/timelog_create.html create mode 100644 internal/twprojects/timelog_app.go create mode 100644 internal/twprojects/timelog_app_test.go diff --git a/internal/twprojects/apps/timelog_create.html b/internal/twprojects/apps/timelog_create.html new file mode 100644 index 00000000..b1729072 --- /dev/null +++ b/internal/twprojects/apps/timelog_create.html @@ -0,0 +1,872 @@ + + + + + + Create Timelog + + + +
+

Log time

+

Create a Teamwork timelog using the current MCP connection.

+ +
+
+ + +
Pick a project to continue.
+
+ +
+ + +
If no task is selected, time is logged directly against the project.
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ +
+ +
+ +
+
+
+ + + + diff --git a/internal/twprojects/timelog_app.go b/internal/twprojects/timelog_app.go new file mode 100644 index 00000000..ea8f4d97 --- /dev/null +++ b/internal/twprojects/timelog_app.go @@ -0,0 +1,44 @@ +package twprojects + +import ( + "context" + _ "embed" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/teamwork/mcp/internal/toolsets" +) + +const ( + mcpAppMimeType = "text/html;profile=mcp-app" + timelogCreateAppResourceURI = "ui://teamwork/timelog-create" + timelogCreateAppResourceTitle = "Create Timelog App" + timelogCreateAppResourceTemplate = "ui://teamwork/timelog-create" +) + +//go:embed apps/timelog_create.html +var timelogCreateAppHTML string + +// TimelogCreateAppResourceTemplate registers the MCP Apps resource used by the +// twprojects-create_timelog tool. +func TimelogCreateAppResourceTemplate() toolsets.ServerResourceTemplate { + return toolsets.NewServerResourceTemplate( + &mcp.ResourceTemplate{ + Name: "twprojects-create_timelog-ui", + Title: timelogCreateAppResourceTitle, + Description: "Interactive form for creating Teamwork timelogs.", + MIMEType: mcpAppMimeType, + URITemplate: timelogCreateAppResourceTemplate, + }, + func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: req.Params.URI, + MIMEType: mcpAppMimeType, + Text: timelogCreateAppHTML, + }, + }, + }, nil + }, + ) +} diff --git a/internal/twprojects/timelog_app_test.go b/internal/twprojects/timelog_app_test.go new file mode 100644 index 00000000..e34c1bfd --- /dev/null +++ b/internal/twprojects/timelog_app_test.go @@ -0,0 +1,93 @@ +package twprojects_test + +import ( + "net/http" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/teamwork/mcp/internal/twprojects" +) + +func connectProjectsClientSession(t *testing.T, mcpServer *mcp.Server) *mcp.ClientSession { + t.Helper() + + clientTransport, serverTransport := mcp.NewInMemoryTransports() + _, err := mcpServer.Connect(t.Context(), serverTransport, nil) + if err != nil { + t.Fatalf("failed to connect test server: %v", err) + } + + client := mcp.NewClient(&mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + }, nil) + clientSession, err := client.Connect(t.Context(), clientTransport, nil) + if err != nil { + t.Fatalf("failed to connect test client: %v", err) + } + return clientSession +} + +func TestTimelogCreateHasMCPAppsMeta(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusCreated, []byte(`{"timelog":{"id":123}}`)) + clientSession := connectProjectsClientSession(t, mcpServer) + defer clientSession.Close() //nolint:errcheck + + tools, err := clientSession.ListTools(t.Context(), nil) + if err != nil { + t.Fatalf("failed to list tools: %v", err) + } + + var createTimelogTool *mcp.Tool + for _, tool := range tools.Tools { + if tool.Name == twprojects.MethodTimelogCreate.String() { + createTimelogTool = tool + break + } + } + if createTimelogTool == nil { + t.Fatal("twprojects-create_timelog tool not found") + } + + uiMetaRaw, ok := createTimelogTool.Meta["ui"] + if !ok { + t.Fatalf("expected _meta.ui on %s", twprojects.MethodTimelogCreate) + } + uiMeta, ok := uiMetaRaw.(map[string]any) + if !ok { + t.Fatalf("expected _meta.ui to be map[string]any, got %T", uiMetaRaw) + } + + resourceURI, ok := uiMeta["resourceUri"].(string) + if !ok || resourceURI == "" { + t.Fatalf("expected _meta.ui.resourceUri to be non-empty string, got %#v", uiMeta["resourceUri"]) + } + if resourceURI != "ui://teamwork/timelog-create" { + t.Fatalf("unexpected resource URI %q", resourceURI) + } +} + +func TestTimelogCreateResourceRead(t *testing.T) { + mcpServer := mcpServerMock(t, http.StatusCreated, []byte(`{"timelog":{"id":123}}`)) + clientSession := connectProjectsClientSession(t, mcpServer) + defer clientSession.Close() //nolint:errcheck + + result, err := clientSession.ReadResource(t.Context(), &mcp.ReadResourceParams{ + URI: "ui://teamwork/timelog-create", + }) + if err != nil { + t.Fatalf("failed to read timelog resource: %v", err) + } + if len(result.Contents) != 1 { + t.Fatalf("expected exactly 1 resource content block, got %d", len(result.Contents)) + } + + content := result.Contents[0] + if content.MIMEType != "text/html;profile=mcp-app" { + t.Fatalf("unexpected mimeType: %q", content.MIMEType) + } + if !strings.Contains(content.Text, "Create Timelog") { + t.Fatalf("expected embedded HTML to contain heading, got: %q", content.Text) + } +} diff --git a/internal/twprojects/timelogs.go b/internal/twprojects/timelogs.go index 881ff52d..9c5c1174 100644 --- a/internal/twprojects/timelogs.go +++ b/internal/twprojects/timelogs.go @@ -66,6 +66,11 @@ func init() { func TimelogCreate(engine *twapi.Engine) toolsets.ToolWrapper { return toolsets.ToolWrapper{ Tool: &mcp.Tool{ + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": timelogCreateAppResourceURI, + }, + }, Name: string(MethodTimelogCreate), Description: "Create a new timelog in Teamwork.com. " + timelogDescription, Annotations: &mcp.ToolAnnotations{ diff --git a/internal/twprojects/tools.go b/internal/twprojects/tools.go index a68fcef0..cc151efa 100644 --- a/internal/twprojects/tools.go +++ b/internal/twprojects/tools.go @@ -64,7 +64,7 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool } group := toolsets.NewToolsetGroup(readOnly) - group.AddToolset(toolsets.NewToolset("projects", projectDescription). + projectToolset := toolsets.NewToolset("projects", projectDescription). AddWriteTools(writeTools...). AddReadTools( ProjectGet(engine), @@ -115,9 +115,16 @@ func DefaultToolsetGroup(readOnly, allowDelete bool, engine *twapi.Engine) *tool SkillList(engine), JobRoleGet(engine), JobRoleList(engine), - ). - AddPrompts( - TaskSkillsAndRolesPrompt(engine), - )) + ) + if !readOnly { + projectToolset.AddResourceTemplates( + TimelogCreateAppResourceTemplate(), + ) + } + projectToolset.AddPrompts( + TaskSkillsAndRolesPrompt(engine), + ) + + group.AddToolset(projectToolset) return group }