Skip to content

test(mcp): full tool-surface e2e suite against fake API - #446

Merged
pcfreak30 merged 26 commits into
developfrom
feat/mcp-tool-suite
Aug 22, 2026
Merged

test(mcp): full tool-surface e2e suite against fake API#446
pcfreak30 merged 26 commits into
developfrom
feat/mcp-tool-suite

Conversation

@pcfreak30

@pcfreak30 pcfreak30 commented Aug 22, 2026

Copy link
Copy Markdown
Member

Full Sunpeak MCP e2e suite covering the tool surface (auth, account, pins, dns, ipns, websites, operations, meta/wizard/resources), driven through invoke_tool against the swagger-generated fake API. Fake extended as a contract-accurate in-memory implementation wherever a domain tool needed endpoints, plus race-clean Go unit tests for those handlers.

Key constraints kept in this PR:

  • All domain calls route through invoke_tool (the real host/progressive-disclosure path), asserting real structured data.
  • Go fake is race-clean under go test -race (shared-state access locked).
  • E2E runs serially across the two host projects sharing one fake.
  • Destructive tools (pins_rm, websites_delete, api_keys_delete, etc.) assert the SafetyDestructive handoff rather than deleting.

CI green (mcp-test + coverage).

Summary

This PR adds a comprehensive end-to-end test suite for the MCP tool surface, covering authentication, pins management, meta-tools (progressive disclosure), and the tool catalog contract.

Key Changes

Fake API Server Enhancement

  • Added a PostApiAuthPing endpoint to the fake account server that verifies authentication and echoes the provided token, enabling the auth_status tool to validate stored credentials.

New Test Suite (tests/sunpeak/mcp-e2e/)

Shared Test Helpers (helpers.ts)

  • Centralized MCP call wrappers (invoke, textOf, isCleanSuccess, describeTool, searchTool) that route all domain tool calls through the invoke_tool meta-tool, mirroring how ChatGPT/Claude hosts surface tools.

Tool Surface Contract Test (tool-surface.test.ts)

  • Exact snapshot assertion: Locks the precise list of tools advertised via tools/list (49 curated tools + 3 meta-tools), ensuring no hidden catalog tools leak through and no curated ones disappear.
  • Guard rails: Explicitly denies discovery of account_info, auth_login, and any tool with dns_, ipns_, operations_, or api_keys_ prefixes.
  • Schema validation: Validates every advertised tool has a non-empty description and a non-null input schema.

Meta-Tools Test (meta-tools.test.ts)

  • search_tools: Validates keyword searches surface the expected tool families (e.g., all pins_* tools), and that empty/help queries return the curated start-here orientation set.
  • describe_tool: Confirms input schema introspection for tools with required args (pins_add requires cids) and tools with no required args (account_info).
  • invoke_tool error paths: Verifies clean error handling for unknown tool names and missing required arguments.
  • Orientation tools: Locks the structured output of capabilities (transport, source modes) and agent_guide (onboarding flow chains).

Authentication Test (auth.test.ts)

  • auth_status: Verifies the seeded fixture credential reports as authenticated with the correct account details.
  • auth_login: Confirms the agent-safe token variant validates JWT shape and returns the logged_in status contract.
  • auth_logout: Validates the local credential clearing contract, transitioning to not_authenticated state.
  • State safety: Captures the pristine config before mutation and restores it in afterAll to avoid de-authenticating the shared fixture used by other tests/host projects.

Pins Domain Test (pins.test.ts)

  • Stateful flow: Ordered tests exercising pins_list (empty) → pins_add (captures request_id) → pins_list (contains pin) → pins_status (round-trip resolution) → pins_rm (destructive gate).
  • Unique CID minting: Each module instance generates a cryptographically-valid unique CIDv1 to isolate flows in the shared fake API store.
  • Destructive operation gating: Confirms pins_rm is blocked by the needs_human/confirmation handoff, and that the pin survives after the gate.

Existing Test Refactor (real-tools.test.ts)

  • Migrated from inline mcp.callTool boilerplate to the shared invoke/isCleanSuccess helpers.
  • Strengthened assertions to verify structured content shapes rather than raw JSON text parsing.

Test Architecture

  • Serial execution: Stateful tests require ordered execution within single workers to maintain module-level state consistency.
  • Progressive disclosure routing: All domain tool calls go through invoke_tool, matching how a real host would call them from the catalog.
  • Fixture safety: Careful handling of shared config state ensures no test leaves the environment de-authenticated or corrupted for subsequent runs.

@kody-ai

This comment has been minimized.

auth := r.Header.Get("Authorization")
const prefix = "Bearer "
token := strings.TrimPrefix(auth, prefix)
writeJSON(w, http.StatusOK, PongResponse{Ping: "pong", Token: token})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Sensitive token exposure: the token variable derives from the Authorization header, which may contain secrets, and storing or exposing the raw token in the response violates security rules. Return only a boolean or a hashed/redacted value, and ensure any token handling uses secure methods for configuration rather than request-derived secrets.

Kody rule violation: Ban hard-coded secrets in Go source

Prompt for LLM

File internal/mcptest/account/server.go:

Line 133:

Sensitive token exposure: the `token` variable derives from the Authorization header, which may contain secrets, and storing or exposing the raw token in the response violates security rules. Return only a boolean or a hashed/redacted value, and ensure any token handling uses secure methods for configuration rather than request-derived secrets.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: this is a test-only fake (internal/mcptest). The echoed token is the deterministic non-secret fixture value "token-e2e@example.com" (not a real credential), and the PongResponse schema's Token field is part of the generated API contract this fake simulates. No production code path is involved.

Comment thread tests/sunpeak/mcp-e2e/auth.test.ts
Comment thread tests/sunpeak/mcp-e2e/auth.test.ts
Comment thread tests/sunpeak/mcp-e2e/meta-tools.test.ts
Comment thread tests/sunpeak/mcp-e2e/pins.test.ts

@kody-ai kody-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Code Coverage Report

Total Coverage: 51.6%

Generated from commit: 7a2d9ca
Repository: LumeWeb/pinner-cli

@kody-ai

kody-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

PR Summary (Comment created by Kody 🤖)

Code Review Started! 🚀

✋ Hi, team! I'm already looking at the changed files and starting the review to ensure everything is in order. If you need more details, I'm here! Kody

📂 Changed Files
File Status ➕ Additions ➖ Deletions 🔄 Changes
internal/mcptest/account/server.go modified 106 3 109
internal/mcptest/account/server_test.go modified 130 0 130
internal/mcptest/ipfs/dns.go added 430 0 430
internal/mcptest/ipfs/dns_test.go added 246 0 246
internal/mcptest/ipfs/server.go modified 17 1 18
internal/mcptest/ipfs/server_test.go modified 3 2 5
tests/sunpeak/mcp-e2e/account.test.ts added 95 0 95
tests/sunpeak/mcp-e2e/dns.test.ts added 200 0 200
📊 Summary of Changes
  • Total Files: 8
  • Total Lines Added: 1227
  • Total Lines Removed: 6
  • Total Changes: 1233

@kody-ai

This comment has been minimized.

// account_update_email / account_update_password tools against the seeded
// account. Accounts registered via the register endpoint store the password
// supplied in the request body instead.
const DefaultPassword = "password"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

The constant DefaultPassword is assigned a hard-coded password string, violating the rule against hard-coded secrets in Go source. Replace the literal with a value read from the environment, e.g., const DefaultPassword = os.Getenv("DEFAULT_PASSWORD").

Kody rule violation: Ban hard-coded secrets in Go source

Prompt for LLM

File internal/mcptest/account/server.go:

Line 41:

The constant DefaultPassword is assigned a hard-coded password string, violating the rule against hard-coded secrets in Go source. Replace the literal with a value read from the environment, e.g., `const DefaultPassword = os.Getenv("DEFAULT_PASSWORD")`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining: DefaultPassword is the deterministic seed password for the test fake's account ("password", not a real secret). It must be a known constant so the e2e harness can drive account_update_email/password against the seeded account. Reading it from the environment would make the fake's seed non-deterministic and break the suite.

Comment thread internal/mcptest/ipfs/dns.go
Comment thread internal/mcptest/ipfs/dns.go Outdated
Comment thread internal/mcptest/ipfs/pins_test.go Outdated
@kody-ai

This comment has been minimized.

@kody-ai

This comment has been minimized.

@kody-ai

This comment has been minimized.

@kody-ai

kody-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

kody code-review Kody Rules medium

The added test setups invoke account_update_email and account_update_password without a context timeout, allowing MCP tool invocations to run indefinitely and potentially overload the database. Wrap each call with a context containing a timeout, e.g., { timeout: 5000 }, to enforce bounded execution time.

Kody rule violation: Disallow GORM queries without timeout

@kody-ai

This comment has been minimized.

Comment on lines +29 to +34
const SEED_EMAIL = 'e2e@example.com';
const SEED_PASSWORD = 'password';

test('account_subscription reports the free, not-subscribed status', async ({ mcp }) => {
// The seeded account is free: is_subscribed=false, no plan period/gateway.
const result = await invoke(mcp, 'account_subscription', {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Removing the beforeAll/afterAll hooks drops the safety net that unconditionally restored the SHARED seeded account (e2e@example.com/password) after each run, leaving the shared account re-keyed if any mutating test aborts between its mutation and restore. Restore an afterAll hook that re-establishes SEED_EMAIL/SEED_PASSWORD unless you prefer to keep the per-test restores as the only protection (acceptable only if you accept cascading failures on mid-test abort).

test.afterAll(async ({ mcp }) => {
  try {
    await invoke(mcp, 'account_update_email', { email: SEED_EMAIL, password: SEED_PASSWORD });
    await invoke(mcp, 'account_update_password', { current_password: SEED_PASSWORD, new_password: SEED_PASSWORD });
  } catch { /* best-effort */ }
});
Prompt for LLM

File tests/sunpeak/mcp-e2e/account.test.ts:

Line 29 to 34:

Removing the beforeAll/afterAll hooks drops the safety net that unconditionally restored the SHARED seeded account (e2e@example.com/password) after each run, leaving the shared account re-keyed if any mutating test aborts between its mutation and restore. Restore an afterAll hook that re-establishes SEED_EMAIL/SEED_PASSWORD unless you prefer to keep the per-test restores as the only protection (acceptable only if you accept cascading failures on mid-test abort).

Suggested Code:

test.afterAll(async ({ mcp }) => {
  try {
    await invoke(mcp, 'account_update_email', { email: SEED_EMAIL, password: SEED_PASSWORD });
    await invoke(mcp, 'account_update_password', { current_password: SEED_PASSWORD, new_password: SEED_PASSWORD });
  } catch { /* best-effort */ }
});

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not applied as written: Playwright does not support the per-test fixture in beforeAll/afterAll ("context/page fixtures are not supported in beforeAll/afterAll"), which is why those hooks were removed. The shared account is guarded instead by: the per-test restores inside each mutation test, auth.test.ts immediate-restoring the shared config after both auth_login and auth_logout, and the suite passing green in CI (132 passed).

@kody-ai

This comment has been minimized.

Comment thread internal/mcptest/ipfs/dns.go Outdated
@kody-ai

This comment has been minimized.

@kody-ai

This comment has been minimized.

writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
s.mu.Lock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

GetApiOperations ignores all bound query params (status/operation/protocol/cid/search/sort/pagination) and returns every seeded operation unfiltered. The e2e test operations_list filters by status (tests/sunpeak/mcp-e2e/operations.test.ts:26-32) passes only by accident because the fake drops the StatusFilter that the SDK forwards as a server-side query param. Apply the status/operation/protocol/cid filters and pagination from params to the returned rows, mirroring the real API's behavior.

data := make([]OperationListItem, 0, len(s.operations))
for _, op := range s.operations {
    if params.Status != nil && string(op.Status) != *params.Status {
        continue
    }
    item := OperationListItem{ ... }
    data = append(data, item)
}
total := len(data)
Prompt for LLM

File internal/mcptest/account/server.go:

Line 350:

GetApiOperations ignores all bound query params (status/operation/protocol/cid/search/sort/pagination) and returns every seeded operation unfiltered. The e2e test operations_list filters by status (tests/sunpeak/mcp-e2e/operations.test.ts:26-32) passes only by accident because the fake drops the StatusFilter that the SDK forwards as a server-side query param. Apply the status/operation/protocol/cid filters and pagination from params to the returned rows, mirroring the real API's behavior.

Suggested Code:

data := make([]OperationListItem, 0, len(s.operations))
for _, op := range s.operations {
    if params.Status != nil && string(op.Status) != *params.Status {
        continue
    }
    item := OperationListItem{ ... }
    data = append(data, item)
}
total := len(data)

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}
}

// GetApiOperations lists account operations (GET /api/operations).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

GetApiOperations receives GetApiOperationsParams but never reads FiltersStatusEq, FiltersIdEq, or _start/_end, so server-side filtering and pagination silently no-op. The SDK's OperationsServiceDefault.List forwards these filters as query params, causing the e2e test (tests/sunpeak/mcp-e2e/operations.test.ts:26-32) to fail its assertion. Honor the bound params by skipping operations that mismatch FiltersStatusEq/FiltersIdEq and applying _start/_end pagination before building the response.

func (s *Server) GetApiOperations(w http.ResponseWriter, r *http.Request, params GetApiOperationsParams) {
	if s.authorize(r) == nil { writeJSON(w, http.StatusUnauthorized, ...); return }
	s.mu.Lock()
	data := make([]OperationListItem, 0, len(s.operations))
	for _, op := range s.operations {
		if params.FiltersStatusEq != nil && op.Status != *(*params.FiltersStatusEq)["eq"] {
			continue
		}
		if params.FiltersIdEq != nil && op.Id != *params.FiltersIdEq {
			continue
		}
		data = append(data, itemFromOp(op))
	}
	total := len(data)
	s.mu.Unlock()
	writeJSON(w, http.StatusOK, OperationListItemResponse{Data: data, Total: total})
}
Prompt for LLM

File internal/mcptest/account/server.go:

Line 344:

GetApiOperations receives GetApiOperationsParams but never reads FiltersStatusEq, FiltersIdEq, or _start/_end, so server-side filtering and pagination silently no-op. The SDK's OperationsServiceDefault.List forwards these filters as query params, causing the e2e test (tests/sunpeak/mcp-e2e/operations.test.ts:26-32) to fail its assertion. Honor the bound params by skipping operations that mismatch FiltersStatusEq/FiltersIdEq and applying _start/_end pagination before building the response.

Suggested Code:

func (s *Server) GetApiOperations(w http.ResponseWriter, r *http.Request, params GetApiOperationsParams) {
	if s.authorize(r) == nil { writeJSON(w, http.StatusUnauthorized, ...); return }
	s.mu.Lock()
	data := make([]OperationListItem, 0, len(s.operations))
	for _, op := range s.operations {
		if params.FiltersStatusEq != nil && op.Status != *(*params.FiltersStatusEq)["eq"] {
			continue
		}
		if params.FiltersIdEq != nil && op.Id != *params.FiltersIdEq {
			continue
		}
		data = append(data, itemFromOp(op))
	}
	total := len(data)
	s.mu.Unlock()
	writeJSON(w, http.StatusOK, OperationListItemResponse{Data: data, Total: total})
}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}
}

func TestOperationsListAndGet(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

No secret detected in this line. No action required.

Kody rule violation: Ban hard-coded secrets in Go source

Prompt for LLM

File internal/mcptest/account/server_test.go:

Line 234:

No secret detected in this line. No action required.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment on lines +301 to +309
s.mu.Lock()
// A concurrent DeleteApiDnsZonesId may have removed this zone (and its
// record map) between the zoneByID check above and the lock here; guard
// against the nil-map panic by (re)creating the map under the lock.
if s.records[zid] == nil {
s.records[zid] = map[string]*dnsRecord{}
}
s.records[zid][recordKey(name, recordType, body.Content)] = rec
s.mu.Unlock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug medium

Recreating the record map under a concurrent DeleteApiDnsZonesId stores data under a zone id that no longer exists in s.zones, leaving orphaned records unreachable by zone listing or GET requests. Under the lock, verify the zone still exists in s.zones before (re)creating the map; if absent, return 404.

s.mu.Lock()
if _, ok := s.zones[zid]; !ok {
    s.mu.Unlock()
    writeNotFound(w)
    return
}
if s.records[zid] == nil {
    s.records[zid] = map[string]*dnsRecord{}
}
s.records[zid][recordKey(name, recordType, body.Content)] = rec
s.mu.Unlock()
Prompt for LLM

File internal/mcptest/ipfs/dns.go:

Line 301 to 309:

Recreating the record map under a concurrent DeleteApiDnsZonesId stores data under a zone id that no longer exists in s.zones, leaving orphaned records unreachable by zone listing or GET requests. Under the lock, verify the zone still exists in s.zones before (re)creating the map; if absent, return 404.

Suggested Code:

s.mu.Lock()
if _, ok := s.zones[zid]; !ok {
    s.mu.Unlock()
    writeNotFound(w)
    return
}
if s.records[zid] == nil {
    s.records[zid] = map[string]*dnsRecord{}
}
s.records[zid][recordKey(name, recordType, body.Content)] = rec
s.mu.Unlock()

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

@kody-ai

kody-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Kody Review Complete

Great news! 🎉
No issues were found that match your current review configurations.

Keep up the excellent work! 🚀

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

@pcfreak30
pcfreak30 marked this pull request as ready for review August 22, 2026 05:59
@pcfreak30
pcfreak30 merged commit 66d8332 into develop Aug 22, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant