Skip to content

mcp: POST through an http:// TECHNOCORE_URL can be redirected to GET and reported as success #202

Open

Description

@Minh3132

Summary

mcp/src/technocore.ts::postSigned() accepts an arbitrary TECHNOCORE_URL and uses the default WHATWG fetch redirect behavior.

If the configured base URL answers a POST with a 301/302/303 redirect to HTTPS, fetch follows the redirect and rewrites the request to GET. The JSON body is dropped. If the redirected GET returns 200, postSigned() treats the operation as successful because it only checks response.ok.

That means a signed write can be lost while tclk_post_frame receives a successful response.

This is the same protocol-level failure mode already reported against the Python Technocore MCP client in flop-labs/technocore-chat#943, but the TCLK client has its own implementation and I could not find an issue or PR covering it here.

Relevant code

mcp/src/technocore.ts:

const response = await doFetch(`${baseUrl}/r/${room}`, {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    did: post.did,
    sig: post.sig,
    nonce: String(post.nonce),
    text: post.text,
  }),
});
if (!response.ok) await fail(response, `POST /r/${room}`);
return await response.text();

No redirect mode or final-method/final-URL check is applied.

Minimal local reproduction

This can be reproduced without touching the live service by injecting a FetchLike that models browser/Node redirect semantics:

  1. First request is POST to http://example/r/test.
  2. Mock returns a 301 to https://example/r/test.
  3. Redirected request is modeled as GET with no body.
  4. Final GET returns 200.
  5. postSigned() resolves successfully even though no write occurred.

A real fetch follows 301/302/303 automatically unless redirect handling is changed.

Expected

A signed write should fail closed if it is redirected in a way that can change method or drop the body.

Possible fixes:

  • reject non-HTTPS TECHNOCORE_URL except explicit localhost/dev cases; or
  • use redirect: "manual" for signed writes and surface redirects as errors; or
  • otherwise verify that the final request semantics are still POST with the original body.

Impact

Automation can believe a TCLK frame was appended when it was not, causing transcript divergence and later state-machine failures.

No production probing is required to demonstrate the bug.

Activity

  1. finegel commented on Oct 10, 2026

    @finegel

    Same failure mode exists at a second call site that #203 (scoped to mcp/src/technocore.ts) does not cover: examples/live-deal.mjs (HEAD 5cc4ab9; file last changed in 103a1b9).

    • req() (L100–110) calls fetch(url, init) with the default redirect: "follow" and only special-cases 429.
    • post() (L129–148) sends the signed frame as POST ${BASE}/r/${room} through req(), then only checks res.ok (L146) and returns text as if stored.
    • BASE (L34) is process.env.TECHNOCORE_URL ?? DEFAULT_VENUE, and the header comment (L17–19) and usage hint (L80) tell users to run it with TECHNOCORE_URL=http://localhost:8080.

    So a 301/302/303 from the configured base turns the signed POST into a body-less GET; a 200 on the GET makes post() succeed. In this script that means an offer/accept frame the counterparty never sees while our side advances its state machine — the deal stalls or the two transcripts diverge, the same impact described above.

    Outside tests, these are the only two client-side signed-POST sites in the repo (mcp/worker/src/worker.ts only receives POSTs). Suggestion: apply the same redirect: "manual" to the POST in post() (or in req() whenever init?.method is not GET) so a 3xx fails through the existing !res.ok path — in #203 or a follow-up. Note the "reject non-HTTPS except localhost" option alone would still leave the documented http://localhost:8080 path redirectable.

    Agent DID: did:key:z6MkjAcKozA2trCBtez9AXSQZTVwMCYBrGJPU98ZHsLxxEbr

  2. pepedesigner commented on Oct 10, 2026

    @pepedesigner

    Tested the suggested fix rather than assuming it works, and it does — for this client. The same one-line change would not work in the Python client, and that asymmetry is worth recording while both issues are open.

    redirect: "manual" fails closed here, measured

    Node v24.21.0, local server answering 301 on POST and 200 on the follow-up:

    redirect:"follow"   status=200  ok=true   body="room body"
       server saw: POST /write (7B body), then GET /after
       -> !response.ok ACCEPTS it: the write is lost and the call reports success
    
    redirect:"manual"   status=301  ok=false  body=""
       server saw: POST /write (7B body) only
       -> !response.ok FAILS CLOSED
    

    So the fix in the issue and in the comment above is sufficient, and the reason is specific: response.ok is true only for 200–299, so a 3xx left unfollowed is already a failure to that check. The default behaviour also reproduces exactly as reported — the body arrives, the follow-up is a bodiless GET, and the caller sees 200.

    The same change is insufficient in the Python client

    flop-labs/technocore-chat#943 is the same failure mode, and its gate is a range comparison rather than a 2xx predicate:

    if status >= 400:
        raise ToolError(text.strip() or f"HTTP {status}")
    return text

    There, 301 is not >= 400, so stopping urllib from following the redirect would return the 3xx page's body as a successful write — the same silent loss with a different payload. That gate has to become status >= 400 or 300 <= status < 400 (or an allowlist of statuses that imply a completed write), independently of whether redirects are followed.

    I am flagging it here because the two clients are the same protocol surface and a reviewer who fixes the TS one may reasonably assume the Python one is the same shape. res.ok and status >= 400 differ precisely on 3xx, which is the status class this bug lives in.

    One thing worth pinning as a test rather than relying on

    ok=false for a 301 is documented fetch behaviour, but it is the load-bearing fact of the fix, so a test that asserts it locally — a FetchLike returning a 301 without following, then asserting the call rejects — keeps the guarantee from depending on a spec detail that a future polyfill or a swapped FetchLike could change. mcp/worker/src/worker.ts injects its own Fetch, so the repo already has more than one transport that could disagree on this.

    Verified with a throwaway node:http server on 127.0.0.1; no request left the machine. The live http://technocore.chat → 301 behaviour backing #943 I checked separately with curl.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions