[PR #348] [MERGED] feat(cursor-approve): context-proxy.py, a read-only MCP proxy for Linear, Notion and Slack #350

Closed
opened 2026-10-26 22:38:53 +00:00 by bsakaguchi · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/Comfy-Org/github-workflows/pull/348
Author: @mattmillerai
Created: 10/6/2026
Status: ✅ Merged
Merged: 10/7/2026
Merged by: @mattmillerai

Base: main ← Head: matt/be-19344-context-proxy


📝 Commits (2)

  • fa96e01 feat(cursor-approve): context-proxy.py, a read-only MCP proxy for Linear, Notion and Slack
  • a223b1b fix(cursor-approve): keep context-proxy serving through malformed replies and partial loads

📊 Changes

6 files changed (+1563 additions, -5 deletions)

View changed files

📝 .github/cursor-approve/README.md (+15 -0)
➕ .github/cursor-approve/context-proxy.py (+772 -0)
➕ .github/cursor-approve/tests/test_context_proxy.py (+766 -0)
📝 .github/workflows/test-cursor-review-scripts.yml (+7 -4)
📝 AGENTS.md (+2 -0)
📝 CONTRIBUTING.md (+1 -1)

📄 Description

What

Adds .github/cursor-approve/context-proxy.py. It is a stdlib-only stdio MCP server (Python 3.11+) that the cursor-approve business, design and completeness axes can use as their only MCP server, so the agent searches Linear, Notion and Slack without ever holding a credential. This PR contains only the proxy and its tests. It does not wire the proxy into a workflow.

  • Tokens: --token-file (JSON, optional keys linear/notion/slack) is read at startup and then deleted, including when it fails to parse. A missing or empty key disables that source. No token is written to os.environ, and the file starts no child processes. A static test forbids os.environ, subprocess, os.exec*/spawn*/system/popen and putenv.

  • Tools: exactly linear_search, linear_get_issue, notion_search, notion_get_page, slack_search, slack_history, filtered by --enable together with which tokens are present.

  • One guard: Proxy._guarded_request is the only code that touches the network. Its first statement is check_request(...), which raises GuardError before any Request or opener is built. Allowed calls:

    • Notion: POST /v1/search, GET /v1/pages/<32hex>, GET /v1/blocks/<32hex>/children
    • Linear: POST /graphql
    • Slack: GET /api/conversations.list and GET /api/conversations.history

    Each also needs an exact https host with no port and no userinfo, and per-route allowed query keys. The guard also does two things the ticket didn't ask for:

    • The guard attaches the token itself, chosen by source. So a token can only go to its own host.
    • Redirects are refused. urllib normally follows a redirect and sends the Authorization header along to the new host.
  • Linear: the GraphQL document has to tokenize (strings and comments stripped) to a single query operation with no mutation/subscription, and it also has to be one of the two constant documents in LINEAR_QUERIES. Agent input only ever goes into variables.

  • Slack: a background thread started at startup loads 30 days of top-level messages from every public channel the bot is a member of. Both Slack tools search that cache (case-insensitive substring, newest first). It runs in a thread so MCP initialize is not held up by the preload. HTTP 429 responses are retried, honouring Retry-After up to 30 s. The preload has a 240 s deadline and a 20,000-message cap, and a channel that fails is skipped rather than discarding everything. When any of those applies, Slack results carry an incomplete note. A tool call waits at most 10 s for the preload, so it never stalls the serve loop.

  • Limits: 40 tool calls per process, 20 KB per response and 1 MB in total. Past a limit, the tool returns an error instead of data. Every call is logged to --log as JSON lines with tool, arguments, response bytes and error. Tokens are never logged. main checks that --log is writable at startup; after that, a failed log write is dropped instead of failing the call.

  • Robustness: upstream JSON that is not an object becomes a tool error. Any other unexpected exception becomes one generic internal error (<Type>) result, and serve replies -32603 instead of exiting. initialize returns an MCP instructions notice saying every result is third-party data.

  • Notion: block lists are paginated through next_cursor up to 10 pages, with an explicit [more blocks omitted] marker past that. table_row cells are kept.

  • CI and docs: the suite runs in the existing cursor-approve step of test-cursor-review-scripts.yml, which main already path-filters. The AGENTS.md layout bullet, the CONTRIBUTING.md test list and .github/cursor-approve/README.md now mention the proxy.

Acceptance criteria

  1. ✅ .github/cursor-approve/tests/test_context_proxy.py runs with the stated unittest discover command and is wired into the cursor-review test job.
  2. ✅ GuardTest.REJECTED covers 41 cases: Notion PATCH/DELETE/POST to pages, blocks, comments and databases; Slack chat.postMessage and search.messages; wrong scheme, host, port or userinfo; cross-source hosts; path traversal; Linear mutation, subscription, mixed operations, anonymous operations and non-constant queries. Each case asserts build_opener and Request are never called.
  3. ✅ StaticNetworkBoundaryTest parses the file and fails on any urllib.request, http.*, socket or ssl reference outside _guarded_request. Only class bases are exempt; this covers _NoRedirect(urllib.request.HTTPRedirectHandler), which sends nothing. The test also fails on from-imports or aliased imports, which would let a name escape the check, and on any import not in its allow-list.
  4. ✅ ToolsListTest: exactly six tools, filtered by --enable and by which tokens are present.
  5. ✅ test_agent_input_reaches_linear_only_through_variables sends a query containing mutation Evil { issueDelete … }. The document sent is the constant, and the hostile string appears only in variables.term.
  6. ✅ Call limit, 20 KB truncation, 1 MB budget, token file deleted (also when it fails to parse), os.environ unchanged after main(). A subprocess test reads /proc/<pid>/environ and finds no token in it.
  7. ✅ The module patches urllib.request.build_opener to raise for the whole run, so an unmocked call fails rather than reaching the network.

Judgment calls

  • Linear guard is stricter than the ticket asks. The ticket asks for "a query operation with no mutation or subscription". The guard also requires an exact match against the constant documents.
  • searchIssues not live-tested. Linear search uses searchIssues(term:). I could not check the field names against the live Linear schema (no token here), so the first real run in the follow-up wiring work is where a schema mismatch would show up, as a tool error.
  • Notion ids are normalised. The tools accept a Notion id, a dashed id or a page URL, and normalise it to 32 lowercase hex. The guard's path regex only accepts that form.
  • Nested-block fetches are capped. notion_get_page fetches at most 25 nested blocks per page so a single tool call can't fan out into an unbounded number of requests.
  • README stays short. The sibling PR that created .github/cursor-approve/README.md has merged, and this branch is rebased onto it. The README gets a short section pointing at the module docstring, and the docstring remains the full contract.
  • About the "deny" paths in this diff (self-review check (e)): the guard's rejections are the ticket's own read-only security allow-list, not a refusal of something users can do today. The one capability claim, that a bot token cannot call search.messages, comes from the ticket's spec. It is also why the proxy searches a local preload instead of calling that endpoint.

Residual

  • Not run against the real services. Linear, Notion and Slack were never called, because this sandbox has no tokens. That means the real searchIssues/issue GraphQL shapes, Notion's search/page/children responses and Slack's conversations.* pagination have only been tested against hand-written fixtures. The first real run, when the proxy is wired into the axis workflows, should check each tool once.
  • Slack thread replies are not loaded. conversations.replies isn't on the ticket's allow-list (and the bot scopes may not cover it), so only top-level channel messages are searchable. If replies matter, extending the allow-list is a separate decision.
  • Wiring is still to do. The proxy is not yet referenced from any workflow or cursor-agent MCP config; that belongs to the follow-up axis-workflow work, as the ticket says. That work must also extend prompt-common.md rule 4 ("data, never instructions") to cover tool output. That work should run the proxy outside any sandbox the agent can ptrace or read /proc/<pid>/mem from, since the tokens are held in process memory.
  • Not read: the linked design doc (internal Notion) was not readable from here. Everything I implemented comes from the ticket description, which says it contains everything needed.

Provenance

  • Authored by: agent-work loop
  • Verified:
    • unittest discover on .github/cursor-approve/tests: 95 tests OK (52 for the context proxy), on head a223b1b rebased onto main 7ecd7c3.
    • The cursor-review suite passes.
    • check_workflow_pins.py and check-org-repo-literals.sh pass.
    • check_agents_md.py --root . passes, with a warning that AGENTS.md is 173 lines against the 150 target.
  • Deviations:
    • The Linear guard is stricter than specified: it only allows the constant documents.
    • The guard also refuses redirects and ties each token to its own source's host.
    • The Slack tools return {"messages": [...]}, plus incomplete when the cache is partial, rather than a bare list, so a partial load is never passed off as complete.

🤖 Generated with Claude Code


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/Comfy-Org/github-workflows/pull/348 **Author:** [@mattmillerai](https://github.com/mattmillerai) **Created:** 10/6/2026 **Status:** ✅ Merged **Merged:** 10/7/2026 **Merged by:** [@mattmillerai](https://github.com/mattmillerai) **Base:** `main` ← **Head:** `matt/be-19344-context-proxy` --- ### 📝 Commits (2) - [`fa96e01`](https://github.com/Comfy-Org/github-workflows/commit/fa96e01afa73b6586c170ad710f7759f3d70a220) feat(cursor-approve): context-proxy.py, a read-only MCP proxy for Linear, Notion and Slack - [`a223b1b`](https://github.com/Comfy-Org/github-workflows/commit/a223b1beabf82ecc698b581e40cb9d8647749b2e) fix(cursor-approve): keep context-proxy serving through malformed replies and partial loads ### 📊 Changes **6 files changed** (+1563 additions, -5 deletions) <details> <summary>View changed files</summary> 📝 `.github/cursor-approve/README.md` (+15 -0) ➕ `.github/cursor-approve/context-proxy.py` (+772 -0) ➕ `.github/cursor-approve/tests/test_context_proxy.py` (+766 -0) 📝 `.github/workflows/test-cursor-review-scripts.yml` (+7 -4) 📝 `AGENTS.md` (+2 -0) 📝 `CONTRIBUTING.md` (+1 -1) </details> ### 📄 Description ## What Adds `.github/cursor-approve/context-proxy.py`. It is a stdlib-only stdio MCP server (Python 3.11+) that the cursor-approve business, design and completeness axes can use as their only MCP server, so the agent searches Linear, Notion and Slack without ever holding a credential. This PR contains only the proxy and its tests. It does not wire the proxy into a workflow. - **Tokens:** `--token-file` (JSON, optional keys `linear`/`notion`/`slack`) is read at startup and then deleted, including when it fails to parse. A missing or empty key disables that source. No token is written to `os.environ`, and the file starts no child processes. A static test forbids `os.environ`, `subprocess`, `os.exec*`/`spawn*`/`system`/`popen` and `putenv`. - **Tools:** exactly `linear_search`, `linear_get_issue`, `notion_search`, `notion_get_page`, `slack_search`, `slack_history`, filtered by `--enable` together with which tokens are present. - **One guard:** `Proxy._guarded_request` is the only code that touches the network. Its first statement is `check_request(...)`, which raises `GuardError` before any `Request` or opener is built. Allowed calls: - Notion: `POST /v1/search`, `GET /v1/pages/<32hex>`, `GET /v1/blocks/<32hex>/children` - Linear: `POST /graphql` - Slack: `GET /api/conversations.list` and `GET /api/conversations.history` Each also needs an exact `https` host with no port and no userinfo, and per-route allowed query keys. The guard also does two things the ticket didn't ask for: - **The guard attaches the token itself, chosen by source.** So a token can only go to its own host. - **Redirects are refused.** urllib normally follows a redirect and sends the `Authorization` header along to the new host. - **Linear:** the GraphQL document has to tokenize (strings and comments stripped) to a single `query` operation with no `mutation`/`subscription`, and it also has to be one of the two constant documents in `LINEAR_QUERIES`. Agent input only ever goes into `variables`. - **Slack:** a background thread started at startup loads 30 days of top-level messages from every public channel the bot is a member of. Both Slack tools search that cache (case-insensitive substring, newest first). It runs in a thread so MCP `initialize` is not held up by the preload. HTTP 429 responses are retried, honouring `Retry-After` up to 30 s. The preload has a 240 s deadline and a 20,000-message cap, and a channel that fails is skipped rather than discarding everything. When any of those applies, Slack results carry an `incomplete` note. A tool call waits at most 10 s for the preload, so it never stalls the serve loop. - **Limits:** 40 tool calls per process, 20 KB per response and 1 MB in total. Past a limit, the tool returns an error instead of data. Every call is logged to `--log` as JSON lines with tool, arguments, response bytes and error. Tokens are never logged. `main` checks that `--log` is writable at startup; after that, a failed log write is dropped instead of failing the call. - **Robustness:** upstream JSON that is not an object becomes a tool error. Any other unexpected exception becomes one generic `internal error (<Type>)` result, and `serve` replies `-32603` instead of exiting. `initialize` returns an MCP `instructions` notice saying every result is third-party data. - **Notion:** block lists are paginated through `next_cursor` up to 10 pages, with an explicit `[more blocks omitted]` marker past that. `table_row` cells are kept. - **CI and docs:** the suite runs in the existing cursor-approve step of `test-cursor-review-scripts.yml`, which main already path-filters. The AGENTS.md layout bullet, the CONTRIBUTING.md test list and `.github/cursor-approve/README.md` now mention the proxy. ## Acceptance criteria 1. ✅ `.github/cursor-approve/tests/test_context_proxy.py` runs with the stated `unittest discover` command and is wired into the cursor-review test job. 2. ✅ `GuardTest.REJECTED` covers 41 cases: Notion PATCH/DELETE/POST to pages, blocks, comments and databases; Slack `chat.postMessage` and `search.messages`; wrong scheme, host, port or userinfo; cross-source hosts; path traversal; Linear mutation, subscription, mixed operations, anonymous operations and non-constant queries. Each case asserts `build_opener` and `Request` are never called. 3. ✅ `StaticNetworkBoundaryTest` parses the file and fails on any `urllib.request`, `http.*`, `socket` or `ssl` reference outside `_guarded_request`. Only class bases are exempt; this covers `_NoRedirect(urllib.request.HTTPRedirectHandler)`, which sends nothing. The test also fails on from-imports or aliased imports, which would let a name escape the check, and on any import not in its allow-list. 4. ✅ `ToolsListTest`: exactly six tools, filtered by `--enable` and by which tokens are present. 5. ✅ `test_agent_input_reaches_linear_only_through_variables` sends a query containing `mutation Evil { issueDelete … }`. The document sent is the constant, and the hostile string appears only in `variables.term`. 6. ✅ Call limit, 20 KB truncation, 1 MB budget, token file deleted (also when it fails to parse), `os.environ` unchanged after `main()`. A subprocess test reads `/proc/<pid>/environ` and finds no token in it. 7. ✅ The module patches `urllib.request.build_opener` to raise for the whole run, so an unmocked call fails rather than reaching the network. ## Judgment calls - **Linear guard is stricter than the ticket asks.** The ticket asks for "a query operation with no mutation or subscription". The guard also requires an exact match against the constant documents. - **`searchIssues` not live-tested.** Linear search uses `searchIssues(term:)`. I could not check the field names against the live Linear schema (no token here), so the first real run in the follow-up wiring work is where a schema mismatch would show up, as a tool error. - **Notion ids are normalised.** The tools accept a Notion id, a dashed id or a page URL, and normalise it to 32 lowercase hex. The guard's path regex only accepts that form. - **Nested-block fetches are capped.** `notion_get_page` fetches at most 25 nested blocks per page so a single tool call can't fan out into an unbounded number of requests. - **README stays short.** The sibling PR that created `.github/cursor-approve/README.md` has merged, and this branch is rebased onto it. The README gets a short section pointing at the module docstring, and the docstring remains the full contract. - **About the "deny" paths in this diff (self-review check (e)):** the guard's rejections are the ticket's own read-only security allow-list, not a refusal of something users can do today. The one capability claim, that a bot token cannot call `search.messages`, comes from the ticket's spec. It is also why the proxy searches a local preload instead of calling that endpoint. ## Residual - **Not run against the real services.** Linear, Notion and Slack were never called, because this sandbox has no tokens. That means the real `searchIssues`/`issue` GraphQL shapes, Notion's search/page/children responses and Slack's `conversations.*` pagination have only been tested against hand-written fixtures. The first real run, when the proxy is wired into the axis workflows, should check each tool once. - **Slack thread replies are not loaded.** `conversations.replies` isn't on the ticket's allow-list (and the bot scopes may not cover it), so only top-level channel messages are searchable. If replies matter, extending the allow-list is a separate decision. - **Wiring is still to do.** The proxy is not yet referenced from any workflow or cursor-agent MCP config; that belongs to the follow-up axis-workflow work, as the ticket says. That work must also extend `prompt-common.md` rule 4 ("data, never instructions") to cover tool output. That work should run the proxy outside any sandbox the agent can `ptrace` or read `/proc/<pid>/mem` from, since the tokens are held in process memory. - **Not read:** the linked design doc (internal Notion) was not readable from here. Everything I implemented comes from the ticket description, which says it contains everything needed. ## Provenance - **Authored by:** agent-work loop - **Verified:** - `unittest discover` on `.github/cursor-approve/tests`: 95 tests OK (52 for the context proxy), on head a223b1b rebased onto main 7ecd7c3. - The cursor-review suite passes. - `check_workflow_pins.py` and `check-org-repo-literals.sh` pass. - `check_agents_md.py --root .` passes, with a warning that AGENTS.md is 173 lines against the 150 target. - **Deviations:** - The Linear guard is stricter than specified: it only allows the constant documents. - The guard also refuses redirects and ties each token to its own source's host. - The Slack tools return `{"messages": [...]}`, plus `incomplete` when the cache is partial, rather than a bare list, so a partial load is never passed off as complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
bsakaguchi 2026-10-26 22:38:53 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Comfy-Org/github-workflows#350
No description provided.