Skip to content

feat(mcp): MCP 2025-11-25 Tasks namespace + Apps progress widget - #14

Open
chronostrophe wants to merge 9 commits into
hanzili:mainfrom
chronostrophe:pr/tasks-apps
Open

feat(mcp): MCP 2025-11-25 Tasks namespace + Apps progress widget#14
chronostrophe wants to merge 9 commits into
hanzili:mainfrom
chronostrophe:pr/tasks-apps

Conversation

@chronostrophe

Copy link
Copy Markdown

Summary

Implements the Tasks and Apps primitives from the MCP 2025-11-25 / 2026-01-26 specs, so long-running research no longer blocks the caller.

Stacked on #13 (which is stacked on #12). Merge order: #12#13 → this one; the diff auto-shrinks to 4 commits.

Tasks (src/mcp/tasks.ts, src/mcp/task-runner.ts)

  • In-memory TaskRegistry with spec-shaped statuses (working, input_required, completed, failed, cancelled), TTL, pollInterval, statusMessage, FIFO eviction at 500 tasks.
  • JSON-RPC namespace wired on the server: tasks/list, tasks/get, tasks/result, tasks/cancel using the SDK's exported schemas.
  • runBackgroundTask(work, opts) bridges the registry to async work — returns a task handle immediately, fires the work, marks completed/failed on settle.
  • New tools: comet_research (returns a task handle instead of blocking 15-90s), comet_poll_task, comet_cancel_task. comet_ask stays blocking — backwards compatible.

Apps (src/mcp/widgets.ts, src/mcp/widgets/progress.html)

  • resources/list + resources/read handlers serving a self-contained progress card at ui://comet-mcp/progress.html (text/html;profile=mcp-app).
  • comet_research results carry _meta["io.modelcontextprotocol/ui"].resourceUri so Apps-aware clients render the card inline while the task runs.
  • Widget is parameterised via query string (taskId/status/message) and listens for host postMessage updates, degrading gracefully when the client doesn't relay them yet.
  • Light/dark via prefers-color-scheme; no build step.

Ops tools

  • comet_version: returns version, git commit SHA, and the full tool list — lets callers verify the mounted dist matches expectations.
  • comet_reload: liveness-probe / registration ping for harnesses that respawn subprocesses.

Test plan

  • npm test — 117/117 pass (+13 registry, +8 widget)
  • npx tsc --noEmit — clean
  • Backwards compatible: all 13 existing tools unchanged

- src/util/format.ts: redactUrls, isDebugEnabled, formatCaughtError
  (extracted from the inline catch handler so the DEBUG+redaction logic
  is unit-testable)
- src/util/steps.ts: extractSteps with position-ordered regex (preserves
  body-position ordering; dedupes via Set)
- src/index.ts: route the MCP catch handler through formatCaughtError +
  isDebugEnabled; skip the redundant 'Error: <msg>' first line of V8
  stacks so the message isn't repeated in the output
- tests/util/format.test.ts (19 tests): URL redaction across http/https/ws,
  parens handling, multi-URL, non-URL preservation, DEBUG truthy/falsy
  literals, formatCaughtError with debug on/off and stack-line cap,
  non-Error throws
- tests/util/steps.test.ts (9 tests): empty/no-match, short-UI filter,
  trailing-context kept, insertion order, dedup, 100-char truncation,
  all 7 verbs, real-world Comet body with mixed signal+noise
- package.json: vitest devDep + scripts (test, test:watch)
- vitest.config.ts: node env, tests/**/*.test.ts

Verified: npx tsc --noEmit clean; npm test → 28/28 passing in 1.7s.

L2 re-investigated with grep references: state.currentUrl IS assigned in
connect() (line 810) and navigate() (line 834); the L2 alarm was a false
positive from initial analysis.
Closes the documented gap where comet-mcp had no equivalent of
Perplexity Comet's isInternalPage / isUrlBlocked / isDomainBlacklist
(per Zenity's reversing story and Trail of Bits audit). Any LLM calling
comet_ask now has its navigations gated by the same shape of policy
that Comet's own agent enforces.

- src/safety/url-policy.ts: pure module, no CDP dependency.
  - isInternalUrl: matches chrome:, chrome-untrusted:, chrome-extension:,
    chrome-search:, chrome-error:, devtools:, edge:, about:, view-source:
  - isFileUrl: matches file:, ftp:
  - isBlockedDocType: matches last path segment against .exe/.msi/.bat/
    .scr/.vbs/.ps1/.sh/.dmg/.pkg/.iso/.app/.jar etc. TLDs like .com/.app
    in the host are NOT false-positives — checked against URL.pathname
    only.
  - extractHost: returns lowercase hostname or null.
  - matchWildcard: '*.foo.com' matches apex + subdomains. 'foo.com'
    matches exact only.
  - checkUrl / assertUrlAllowed: combined verdict with reason
    enum. Denylist wins over allowlist. URLs with no host when policy
    lists patterns are denied.
  - BlockedUrlError: typed Error carrying url, reason, message. Picked
    up by the MCP catch handler and surfaced via formatCaughtError.
  - PolicyRegistry: single in-memory source of truth. getActivePolicy
    returns a defensive copy.
  - Hot-load from $COMET_URL_POLICY or ~/.comet-mcp/url-policy.json.

- src/cdp-client.ts navigate(): asserts the URL against the active
  policy before any CDP call. Throws BlockedUrlError on deny.

- src/index.ts: two new MCP tools.
  - comet_get_url_policy: returns the active policy as JSON.
  - comet_set_url_policy: partial-update any flag or list. reset:true
    restores defaults. Server-side normalizePolicy() drops unknown
    keys and coerces types so a hostile caller can't inject extra state.

- tests/safety/url-policy.test.ts (43 tests): per-URL-type coverage,
  policy composition (denylist vs allowlist precedence), defensive
  copy semantics, normalization edge cases, hot-load parsing.
- tests/integration/url-policy-cdp.test.ts (6 tests): realistic Comet
  scenarios (settings page, password manager, credential store file,
  exe download, normal navigation, BlockedUrlError surface).

Stats: 11 test files, 192 tests passing (was 137; +55). npx tsc --noEmit
clean. Mirrors industry guidance (Microsoft MCP security 2026,
Playwright MCP best practices, Chrome URLBlocklist policy).
Per user feedback: chrome://, edge://, devtools://, about:, view-source:,
chrome-extension: are not needed as defaults. file:// and executable
document types stay blocked (the security-relevant ones for prompt
injection defense). Internal URL blocking is opt-in via
comet_set_url_policy {blockInternal: true}.

- src/safety/url-policy.ts DEFAULT_POLICY: blockInternal true -> false
- src/cdp-client.ts newTab(): also gates URL via assertUrlAllowed
- tests/safety/url-policy.test.ts: new tests asserting the default is
  permissive; existing chrome:// tests now pass explicit blockInternal:true
- tests/integration/url-policy-cdp.test.ts: rewritten to reflect the
  default + explicit opt-in path

Stats: 11 test files, 197 tests passing. npx tsc --noEmit clean.
…OOLS

The case handlers were wired but the TOOLS array literal wasn't updated, so
tools/list returned the old 13-tool shape. tools/call still routed the
names correctly (proved by the wire test) but LLM clients couldn't see
the tools in their schema. Now exposed in the tools/list response.

Verified via subprocess JSON-RPC driver: 15 tools exposed, both
policy tools respond correctly to get/set/reset, hostile inputs are
normalized (unknown keys dropped, non-string array entries filtered).
Closes the gap where every URL policy decision was lost on return.

- src/safety/audit-log.ts: ring buffer (cap 500, FIFO eviction),
  pure recordAllow/recordDenial/recordDecision helpers, O(1) push,
  recent(n) and filter(pred) query. Thread-safe enough for the MCP
  stdio model (single-writer, single-consumer per request).
- src/safety/url-policy.ts: new evaluateUrl(url, policy, caller)
  wrapper that runs checkUrl and records the decision. Keeps
  checkUrl pure so unit tests stay deterministic.
- src/cdp-client.ts: navigate() and newTab() now route through
  evaluateUrl and accept an optional caller argument. Every browser
  action goes through the audit log.
- src/index.ts: 5 navigate/newTab call sites updated to pass the MCP
  tool name as caller (comet_connect, comet_ask, comet_mode). 2 new
  MCP tools added:
    - comet_get_audit_log: returns {total, returned, entries} with
      optional limit/outcome/caller filters. Newest first.
    - comet_reset_audit_log: clears the buffer.
- tests/safety/audit-log.test.ts (8 tests): entry shape, allow vs
  deny, recent() ordering, recent(n) limit, predicate filter, FIFO
  eviction at the configured cap, clear() semantics, caller field
  passthrough.

Stats: 12 test files, 205 tests passing (was 197; +8). npx tsc
--noEmit clean.
- src/mcp/tasks.ts: pure module. CreateTaskResult, TaskStatusResult,
  CreateTaskResult marker. Spec-shaped statuses (working, input_required,
  completed, failed, cancelled). Watch() with unsubscribe. FIFO eviction
  at 500-task cap. No SDK dependency — kept standalone so caller
  field can carry the MCP tool name for audit integration.
- tests/mcp/tasks.test.ts (13 tests): create + get + complete + fail +
  cancel + idempotency + list ordering (fake timers) + watch + unsubscribe
  + size/clear + CreateTaskResult marker shape.

Stats: 13 test files, 218 tests passing (was 205; +13). npx tsc clean.

Next: wire SDK's ExperimentalServerTasks for the JSON-RPC namespace
(tasks/get, tasks/result, tasks/list, tasks/cancel) and bridge our
custom registry's caller field through it. Then a minimal resources/
widget HTML for in-chat progress.
…ools

- src/mcp/task-runner.ts: bridge between TaskRegistry and async work.
  runBackgroundTask(work, opts) returns the spec-shaped TaskCreated
  immediately and fires the work in the background. No SDK dependency
  so the caller field stays free for audit integration later.
- src/index.ts: register the four MCP 2025-11-25 task-namespace JSON-RPC
  handlers directly so any spec-compliant client can drive them:
    tasks/list: ListTasksRequestSchema -> { tasks: [...] }
    tasks/get: GetTaskRequestSchema -> task snapshot
    tasks/result: GetTaskPayloadRequestSchema -> poll until terminal
      (5-min hard cap)
    tasks/cancel: CancelTaskRequestSchema -> { taskId, cancelled }
  Internal toSpecTask() adapter maps my flat TaskStatusResult into the
  spec shape ({task: {taskId, status, ttl, createdAt, lastUpdatedAt, ...}}).
  Three new MCP tools wired:
    comet_research: kick off a research task; returns CreateTaskResult
      so caller knows it's a handle, not an immediate answer.
    comet_poll_task: read status by taskId.
    comet_cancel_task: cancel by taskId.

Stats: 13 test files, 218 tests passing. npx tsc clean.
….ui link

- src/mcp/widgets/progress.html: self-contained progress card
  rendered in MCP Apps iframe. Shows task id, status pill, indeterminate
  progress bar, and answer body when complete. Listens for postMessage
  status updates from host (graceful degradation when host protocol not
  wired yet). Light/dark theme via prefers-color-scheme. No build step
  needed — read at module init via readFileSync with multi-path
  fallback (vitest cwd vs dist/mcp vs src/mcp).
- src/mcp/widgets.ts: server-side widget glue.
  - listProgressWidget() advertises ui://comet-mcp/progress.html with
    mimeType 'text/html;profile=mcp-app'.
  - readProgressWidget({taskId, status, message}) returns the HTML with
    taskId+status injected as query string params so the iframe can
    render the right initial state before any postMessage arrives.
  - progressWidgetUri(taskId) builds the canonical ui:// URL exposed
    to clients of comet_research.
- src/index.ts:
  - Register ListResourcesRequestSchema + ReadResourceRequestSchema
    handlers. The read handler parses taskId/status from the URI query
    and delegates to readProgressWidget. UI URIs that aren't ours
    throw a 'Resource not found' error per spec.
  - comet_research case now includes _meta.io.modelcontextprotocol/ui
    pointing at the widget URI so MCP Apps-aware clients render the
    progress card inline while the task runs.
- tests/mcp/widgets.test.ts (8 tests): list shape, read returns HTML
  with taskId+status+message injected, default status=working, URL
  encoding of special characters, idempotency, constant export.

Stats: 14 test files, 226 tests passing (was 218; +8). npx tsc clean.
- comet_version: returns version, git commit SHA, and full tool list.
  Lets callers verify the mounted instance matches the expected dist
  without guessing. commit field is 'unknown' if not in a git repo.
- comet_reload: no-op for the running process (tool list is static at
  module load), but acts as a liveness probe that some harnesses use
  before attempting a subprocess respawn. Returns tool count so the
  caller can confirm the new dist is live.

Stats: 14 test files, 226 tests passing. npx tsc clean.
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