Skip to content

Commit 32fead4

Browse files
feat(mcp): add postprocess policy operations tools
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 3f1143d commit 32fead4

8 files changed

Lines changed: 186 additions & 6 deletions

File tree

internal/mcp/deps.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ type IncrementalSyncer interface {
120120
type PostprocessPolicy interface {
121121
Resolve(ctx context.Context, input postprocesspolicy.DecisionInput) (string, string, error)
122122
RecordRun(ctx context.Context, record postprocesspolicy.RunRecord) error
123+
Status(ctx context.Context, opts postprocesspolicy.StatusOptions) (*postprocesspolicy.StatusSummary, error)
124+
Reset(ctx context.Context, tool string) error
123125
}
124126

125127
// Deps collects the services and stores required by MCP handlers.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
6+
"github.com/mark3labs/mcp-go/mcp"
7+
8+
postprocesspolicy "github.com/tae2089/code-context-graph/internal/postprocess/policy"
9+
)
10+
11+
func (h *handlers) getPostprocessPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
12+
ctx = h.applyWorkspace(ctx, request)
13+
if h.deps.PostprocessPolicy == nil {
14+
return mcp.NewToolResultError("postprocess policy engine not configured"), nil
15+
}
16+
tool := request.GetString("tool", "")
17+
if tool != "" && !postprocesspolicy.ValidTool(tool) {
18+
return mcp.NewToolResultError("tool must be build_or_update_graph or run_postprocess"), nil
19+
}
20+
recentLimit := request.GetInt("recent_limit", postprocesspolicy.DefaultStatusLimit)
21+
if err := validatePositiveLimit(recentLimit); err != nil {
22+
return finalizeToolResult("", err)
23+
}
24+
summary, err := h.deps.PostprocessPolicy.Status(ctx, postprocesspolicy.StatusOptions{
25+
Namespace: requestNamespace(request),
26+
Tool: tool,
27+
RecentLimit: recentLimit,
28+
})
29+
if err != nil {
30+
return nil, err
31+
}
32+
result, err := marshalJSON(summary)
33+
return finalizeToolResult(result, err)
34+
}
35+
36+
func (h *handlers) resetPostprocessPolicy(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
37+
ctx = h.applyWorkspace(ctx, request)
38+
if h.deps.PostprocessPolicy == nil {
39+
return mcp.NewToolResultError("postprocess policy engine not configured"), nil
40+
}
41+
tool := request.GetString("tool", "")
42+
if !postprocesspolicy.ValidTool(tool) {
43+
return mcp.NewToolResultError("tool must be build_or_update_graph or run_postprocess"), nil
44+
}
45+
if err := h.deps.PostprocessPolicy.Reset(ctx, tool); err != nil {
46+
return nil, err
47+
}
48+
result, err := marshalJSON(map[string]any{
49+
"status": "ok",
50+
"tool": tool,
51+
"reset": true,
52+
})
53+
return finalizeToolResult(result, err)
54+
}

internal/mcp/handlers_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1653,6 +1653,70 @@ func TestRunPostprocess_RealPolicyStoreIsolatedByNamespaceAndTool(t *testing.T)
16531653
}
16541654
}
16551655

1656+
func TestGetPostprocessPolicy_UsesPolicyStatusSummary(t *testing.T) {
1657+
deps := setupTestDeps(t)
1658+
deps.PostprocessPolicy = &stubPostprocessPolicy{
1659+
statusSummary: &postprocesspolicy.StatusSummary{
1660+
Status: postprocesspolicy.StatusDegraded,
1661+
FailClosed: []postprocesspolicy.StateSnapshot{{
1662+
Namespace: ctxns.DefaultNamespace,
1663+
Tool: postprocesspolicy.ToolRunPostprocess,
1664+
Policy: postprocesspolicy.PolicyFailClosed,
1665+
ConsecutiveFailures: 3,
1666+
}},
1667+
},
1668+
}
1669+
1670+
result := callTool(t, deps, "get_postprocess_policy", map[string]any{"tool": "run_postprocess", "recent_limit": 3})
1671+
if result.IsError {
1672+
t.Fatalf("get_postprocess_policy error: %s", getTextContent(result))
1673+
}
1674+
stub := deps.PostprocessPolicy.(*stubPostprocessPolicy)
1675+
if len(stub.statusInputs) != 1 {
1676+
t.Fatalf("status inputs = %d, want 1", len(stub.statusInputs))
1677+
}
1678+
if stub.statusInputs[0].Tool != postprocesspolicy.ToolRunPostprocess {
1679+
t.Fatalf("status tool = %q, want run_postprocess", stub.statusInputs[0].Tool)
1680+
}
1681+
var resp map[string]any
1682+
if err := json.Unmarshal([]byte(getTextContent(result)), &resp); err != nil {
1683+
t.Fatalf("expected JSON, got: %s", getTextContent(result))
1684+
}
1685+
if resp["status"] != "degraded" {
1686+
t.Fatalf("status = %v, want degraded", resp["status"])
1687+
}
1688+
}
1689+
1690+
func TestResetPostprocessPolicy_RecordsResetForTool(t *testing.T) {
1691+
deps := setupTestDeps(t)
1692+
deps.PostprocessPolicy = &stubPostprocessPolicy{}
1693+
1694+
result := callTool(t, deps, "reset_postprocess_policy", map[string]any{"tool": "run_postprocess"})
1695+
if result.IsError {
1696+
t.Fatalf("reset_postprocess_policy error: %s", getTextContent(result))
1697+
}
1698+
stub := deps.PostprocessPolicy.(*stubPostprocessPolicy)
1699+
if len(stub.resetTools) != 1 {
1700+
t.Fatalf("reset tools = %d, want 1", len(stub.resetTools))
1701+
}
1702+
if stub.resetTools[0] != postprocesspolicy.ToolRunPostprocess {
1703+
t.Fatalf("reset tool = %q, want run_postprocess", stub.resetTools[0])
1704+
}
1705+
}
1706+
1707+
func TestResetPostprocessPolicy_RejectsInvalidTool(t *testing.T) {
1708+
deps := setupTestDeps(t)
1709+
deps.PostprocessPolicy = &stubPostprocessPolicy{}
1710+
1711+
result := callTool(t, deps, "reset_postprocess_policy", map[string]any{"tool": "other"})
1712+
if !result.IsError {
1713+
t.Fatalf("expected invalid tool to fail, got: %s", getTextContent(result))
1714+
}
1715+
if !strings.Contains(getTextContent(result), "tool must be build_or_update_graph or run_postprocess") {
1716+
t.Fatalf("unexpected error: %s", getTextContent(result))
1717+
}
1718+
}
1719+
16561720
// ============================================================
16571721
// 11.3 query_graph
16581722
// ============================================================

internal/mcp/server_test.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ func TestMCPServer_ListTools(t *testing.T) {
1818

1919
expected := []string{
2020
"parse_project",
21+
"get_postprocess_policy",
22+
"reset_postprocess_policy",
2123
"get_node",
2224
"get_impact_radius",
2325
"search",
@@ -77,8 +79,8 @@ func TestMCPServer_ListTools_18(t *testing.T) {
7779
srv := NewServer(deps)
7880
tools := srv.ListTools()
7981

80-
if len(tools) != 31 {
81-
t.Fatalf("expected 31 tools, got %d", len(tools))
82+
if len(tools) != 33 {
83+
t.Fatalf("expected 33 tools, got %d", len(tools))
8284
}
8385
}
8486

@@ -162,9 +164,11 @@ func TestMCPServer_ToolRequiredFlags(t *testing.T) {
162164
srv := NewServer(deps)
163165
tools := srv.ListTools()
164166

165-
expected := map[string][]string{
166-
"parse_project": {"path"},
167-
"get_node": {"qualified_name"},
167+
expected := map[string][]string{
168+
"parse_project": {"path"},
169+
"get_postprocess_policy": nil,
170+
"reset_postprocess_policy": {"tool"},
171+
"get_node": {"qualified_name"},
168172
"get_impact_radius": {"qualified_name"},
169173
"search": {"query"},
170174
"get_annotation": {"qualified_name"},

internal/mcp/testhelpers_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,13 @@ type stubPostprocessPolicy struct {
204204
resolvedSource string
205205
resolveErr error
206206
recordErr error
207+
statusErr error
208+
resetErr error
209+
statusSummary *postprocesspolicy.StatusSummary
207210
resolvedInputs []postprocesspolicy.DecisionInput
208211
recordedRuns []postprocesspolicy.RunRecord
212+
statusInputs []postprocesspolicy.StatusOptions
213+
resetTools []string
209214
}
210215

211216
func (s *stubPostprocessPolicy) Resolve(ctx context.Context, input postprocesspolicy.DecisionInput) (string, string, error) {
@@ -221,6 +226,22 @@ func (s *stubPostprocessPolicy) RecordRun(ctx context.Context, record postproces
221226
return s.recordErr
222227
}
223228

229+
func (s *stubPostprocessPolicy) Status(ctx context.Context, opts postprocesspolicy.StatusOptions) (*postprocesspolicy.StatusSummary, error) {
230+
s.statusInputs = append(s.statusInputs, opts)
231+
if s.statusErr != nil {
232+
return nil, s.statusErr
233+
}
234+
if s.statusSummary == nil {
235+
return &postprocesspolicy.StatusSummary{Status: postprocesspolicy.StatusOK}, nil
236+
}
237+
return s.statusSummary, nil
238+
}
239+
240+
func (s *stubPostprocessPolicy) Reset(ctx context.Context, tool string) error {
241+
s.resetTools = append(s.resetTools, tool)
242+
return s.resetErr
243+
}
244+
224245
type realPostprocessPolicy struct {
225246
engine *postprocesspolicy.Engine
226247
store *postprocesspolicy.Store
@@ -241,6 +262,14 @@ func (p *realPostprocessPolicy) RecordRun(ctx context.Context, record postproces
241262
return p.store.RecordRun(ctx, record)
242263
}
243264

265+
func (p *realPostprocessPolicy) Status(ctx context.Context, opts postprocesspolicy.StatusOptions) (*postprocesspolicy.StatusSummary, error) {
266+
return p.store.Status(ctx, opts)
267+
}
268+
269+
func (p *realPostprocessPolicy) Reset(ctx context.Context, tool string) error {
270+
return p.store.Reset(ctx, tool)
271+
}
272+
244273
func setupTestDepsWithRealPostprocessPolicy(t *testing.T) *Deps {
245274
t.Helper()
246275
deps := setupTestDeps(t)

internal/mcp/tools_parse.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func parseTools(h *handlers) []server.ServerTool {
2323
mcp.WithString("path", mcp.Description("Project directory path to parse"), mcp.Required()),
2424
mcp.WithBoolean("full_rebuild", mcp.Description("If true, do a full rebuild; if false, use incremental sync")),
2525
mcp.WithString("postprocess", mcp.Description("Postprocessing mode: full, minimal, or none (default: full)")),
26-
mcp.WithString("postprocess_policy", mcp.Description("Postprocessing failure policy: degraded or fail_closed (default: degraded)")),
26+
mcp.WithString("postprocess_policy", mcp.Description("Postprocessing failure policy: degraded or fail_closed (default: auto -> degraded, escalates to fail_closed after repeated failures)")),
2727
mcp.WithArray("include_paths", mcp.Description("Only include specific sub-paths (e.g. [\"src/api\", \"src/auth\"])"), mcp.WithStringItems()),
2828
mcp.WithBoolean("replace", mcp.Description("When true (default), incremental include_paths replaces prior namespace graph state outside the included scope; when false, preserves out-of-scope files")),
2929
mcp.WithNumber("max_file_bytes", mcp.Description("Maximum bytes allowed per parsed source file; overrides server default when set")),

internal/mcp/tools_postprocess.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package mcp
2+
3+
import (
4+
"github.com/mark3labs/mcp-go/mcp"
5+
"github.com/mark3labs/mcp-go/server"
6+
)
7+
8+
func postprocessTools(h *handlers) []server.ServerTool {
9+
return []server.ServerTool{
10+
{
11+
Tool: mcp.NewTool("get_postprocess_policy", withNamespaceParam(
12+
mcp.WithDescription("Inspect automatic postprocess policy state, fail_closed entries, and recent failures for the current namespace or across namespaces"),
13+
mcp.WithString("tool", mcp.Description("Optional tool filter: build_or_update_graph or run_postprocess")),
14+
mcp.WithNumber("recent_limit", mcp.Description("Maximum recent failures returned (default: 5)"), mcp.DefaultNumber(5)),
15+
)...),
16+
Handler: h.getPostprocessPolicy,
17+
},
18+
{
19+
Tool: mcp.NewTool("reset_postprocess_policy", withNamespaceParam(
20+
mcp.WithDescription("Reset automatic postprocess failure streak for a tool by recording a reset marker run in the current namespace"),
21+
mcp.WithString("tool", mcp.Description("Tool to reset: build_or_update_graph or run_postprocess"), mcp.Required()),
22+
)...),
23+
Handler: h.resetPostprocessPolicy,
24+
},
25+
}
26+
}

internal/mcp/tools_register.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import "github.com/mark3labs/mcp-go/server"
55
func registerTools(srv *server.MCPServer, h *handlers) {
66
var tools []server.ServerTool
77
tools = append(tools, parseTools(h)...)
8+
tools = append(tools, postprocessTools(h)...)
89
tools = append(tools, queryTools(h)...)
910
tools = append(tools, analysisTools(h)...)
1011
tools = append(tools, graphTools(h)...)

0 commit comments

Comments
 (0)