Skip to content

Commit e883a5c

Browse files
jackwildmangithub-actions[bot]
authored andcommitted
feat(everyrow-cc): add conversation privacy and sharing (#5035)
## Summary - **Phase 1 (Privacy):** Gate agent read endpoints (`/stream`, `/session-history`, `/session-status`) behind owner verification. Express proxy extracts user ID from Supabase auth cookie and forwards `X-User-Id`/`X-Share-Token` headers to the agent service, which stores `owner_user_id` on first `/chat` and rejects unauthorized reads. - **Phase 2 (Sharing):** Add share toggle endpoints, public share metadata endpoint, session-to-conversation linkage, and a read-only share view. Database migration adds `share_token`, `forked_from`, and `cc_conversation_id` columns with RLS policies cascading read access through sessions → tasks → artifacts. Frontend adds `ShareButton` in conversation header and `SharedConversationView` at `/share/[token]`. - **Phase 3 (Forking):** `forked_from` column added to schema for future support — no logic yet. ### Key changes across layers | Layer | Files | What | |-------|-------|------| | Express proxy | `server/auth.ts`, `routes.ts`, `agent-client.ts`, `server.ts` | Cookie-based auth extraction, share token forwarding, new proxy routes | | Agent service | `server.py`, `sdk_manager.py` | Owner storage/verification, share token validation against engine | | Engine API | `cc_conversations.py`, `cc_conversations_types.py`, `db.py`, `main.py` | Share toggle, public share endpoint, session linking, new DB functions | | Frontend | `ShareButton.tsx`, `SharedConversationView.tsx`, `share/[token]/page.tsx`, `AppShell.tsx`, `ChatPane.tsx`, `useSSE.ts`, `conversations-api.ts` | Share UI, read-only public view, session linking on everyrow_session event | | Database | `20260324000000_cc_conversation_sharing.sql` | share_token, forked_from, cc_conversation_id, RLS policies | ## Test plan - [ ] Open a conversation as User A — SSE stream works, chat works - [ ] In incognito (no auth), try `/app/api/session-history?sessionId=<UUID>` — should get 401 - [ ] Log in as User B, try the same — should get 403 - [ ] As User A, click Share toggle — get share URL with copyable link - [ ] Open share URL in incognito — see read-only chat + data view (no input box) - [ ] Revoke share — share URL returns error - [ ] Verify existing conversation flow (create, chat, resume) still works end-to-end - [ ] Run migration against local/docker Supabase and verify columns + policies created 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Sourced from commit 6682622497b22cf60863fdad10c050d960f36567
1 parent 556c3ad commit e883a5c

File tree

8 files changed

+93
-34
lines changed

8 files changed

+93
-34
lines changed

futuresearch-mcp/src/futuresearch_mcp/http_config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,21 @@
4343
"user_agent", default=""
4444
)
4545

46+
_conversation_id_var: contextvars.ContextVar[str] = contextvars.ContextVar(
47+
"conversation_id", default=""
48+
)
49+
4650

4751
def get_user_agent() -> str:
4852
"""Return the User-Agent of the current HTTP request (empty in stdio mode)."""
4953
return _user_agent_var.get()
5054

5155

56+
def get_conversation_id() -> str:
57+
"""Return the X-Conversation-Id of the current HTTP request (empty if absent)."""
58+
return _conversation_id_var.get()
59+
60+
5261
def configure_http_mode(
5362
*,
5463
mcp: FastMCP,
@@ -210,6 +219,9 @@ async def dispatch(self, request, call_next):
210219
# Propagate User-Agent so downstream tool code can detect the client
211220
# even in stateless HTTP mode (no MCP initialize → no client_params).
212221
ua_token = _user_agent_var.set(request.headers.get("user-agent", ""))
222+
cc_token = _conversation_id_var.set(
223+
request.headers.get("x-conversation-id", "")
224+
)
213225
try:
214226
start = _time.monotonic()
215227
response = await call_next(request)
@@ -236,6 +248,7 @@ async def dispatch(self, request, call_next):
236248
return response
237249
finally:
238250
_user_agent_var.reset(ua_token)
251+
_conversation_id_var.reset(cc_token)
239252

240253

241254
def _add_middleware(

futuresearch-mcp/src/futuresearch_mcp/tool_helpers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from futuresearch.generated.models.task_status import TaskStatus
2121
from futuresearch.generated.models.task_status_response import TaskStatusResponse
2222
from futuresearch.generated.types import Unset
23+
from futuresearch.session import create_session
2324
from mcp.server.fastmcp import Context
2425
from mcp.server.session import ServerSession
2526
from mcp.types import TextContent
@@ -60,6 +61,26 @@ def _get_client(ctx: FuturesearchContext) -> AuthenticatedClient:
6061
return ctx.request_context.lifespan_context.client_factory()
6162

6263

64+
def _get_conversation_id() -> str | None:
65+
"""Get the conversation ID from the current HTTP request context, if any."""
66+
try:
67+
from futuresearch_mcp.http_config import get_conversation_id # noqa: PLC0415
68+
69+
val = get_conversation_id()
70+
return val if val else None
71+
except Exception:
72+
return None
73+
74+
75+
def create_linked_session(
76+
client: AuthenticatedClient,
77+
**kwargs: Any,
78+
):
79+
"""Wrapper around SDK create_session that passes conversation_id from HTTP context."""
80+
conv_id = _get_conversation_id()
81+
return create_session(client=client, conversation_id=conv_id, **kwargs)
82+
83+
6384
def log_client_info(ctx: FuturesearchContext, tool_name: str) -> None:
6485
"""Log MCP client identity and capabilities for the current request."""
6586
try:

futuresearch-mcp/src/futuresearch_mcp/tools.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
rank_async,
2727
single_agent_async,
2828
)
29-
from futuresearch.session import create_session, list_sessions
29+
from futuresearch.session import list_sessions
3030
from futuresearch.task import cancel_task
3131
from mcp.types import CallToolResult, TextContent, ToolAnnotations
3232
from pydantic import BaseModel, create_model
@@ -65,6 +65,7 @@
6565
_fetch_task_result,
6666
_get_client,
6767
_record_task_ownership,
68+
create_linked_session,
6869
create_tool_response,
6970
dedupe_summaries,
7071
log_client_info,
@@ -177,7 +178,7 @@ async def futuresearch_use_list(
177178
client = _get_client(ctx)
178179

179180
try:
180-
async with create_session(client=client) as session:
181+
async with create_linked_session(client=client) as session:
181182
result = await use_built_in_list(
182183
artifact_id=UUID(params.artifact_id),
183184
session=session,
@@ -273,7 +274,7 @@ async def futuresearch_agent(
273274
if params.response_schema:
274275
response_model = _schema_to_model("AgentResult", params.response_schema)
275276

276-
async with create_session(
277+
async with create_linked_session(
277278
client=client, session_id=params.session_id, name=params.session_name
278279
) as session:
279280
session_id_str = str(session.session_id)
@@ -359,7 +360,7 @@ async def futuresearch_single_agent(
359360
DynamicInput = create_model("DynamicInput", **fields) # pyright: ignore[reportArgumentType, reportCallIssue]
360361
input_model = DynamicInput()
361362

362-
async with create_session(
363+
async with create_linked_session(
363364
client=client, session_id=params.session_id, name=params.session_name
364365
) as session:
365366
session_id_str = str(session.session_id)
@@ -441,7 +442,7 @@ async def futuresearch_rank(
441442
if params.response_schema:
442443
response_model = _schema_to_model("RankResult", params.response_schema)
443444

444-
async with create_session(
445+
async with create_linked_session(
445446
client=client, session_id=params.session_id, name=params.session_name
446447
) as session:
447448
session_id_str = str(session.session_id)
@@ -515,7 +516,7 @@ async def futuresearch_dedupe(
515516

516517
input_data = params._aid_or_dataframe
517518

518-
async with create_session(
519+
async with create_linked_session(
519520
client=client, session_id=params.session_id, name=params.session_name
520521
) as session:
521522
session_id_str = str(session.session_id)
@@ -607,7 +608,7 @@ async def futuresearch_merge(
607608
left_input = params._left_aid_or_dataframe
608609
right_input = params._right_aid_or_dataframe
609610

610-
async with create_session(
611+
async with create_linked_session(
611612
client=client, session_id=params.session_id, name=params.session_name
612613
) as session:
613614
session_id_str = str(session.session_id)
@@ -685,7 +686,7 @@ async def futuresearch_forecast(
685686

686687
input_data = params._aid_or_dataframe
687688

688-
async with create_session(
689+
async with create_linked_session(
689690
client=client, session_id=params.session_id, name=params.session_name
690691
) as session:
691692
session_id_str = str(session.session_id)
@@ -757,7 +758,7 @@ async def futuresearch_classify(
757758

758759
input_data = params._aid_or_dataframe
759760

760-
async with create_session(
761+
async with create_linked_session(
761762
client=client, session_id=params.session_id, name=params.session_name
762763
) as session:
763764
session_id_str = str(session.session_id)
@@ -827,7 +828,7 @@ async def futuresearch_upload_data(
827828
if df.empty:
828829
raise ValueError(f"CSV file is empty: {params.source}")
829830

830-
async with create_session(
831+
async with create_linked_session(
831832
client=client, session_id=params.session_id, name=params.session_name
832833
) as session:
833834
session_id_str = str(session.session_id)

futuresearch-mcp/tests/test_mcp_e2e.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ async def test_call_agent_tool(self, _http_state):
239239
return_value=mock_task,
240240
),
241241
patch(
242-
"futuresearch_mcp.tools.create_session",
242+
"futuresearch_mcp.tools.create_linked_session",
243243
side_effect=fake_create_session,
244244
),
245245
):

futuresearch-mcp/tests/test_server.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ async def test_submit_returns_task_id(self):
317317
"futuresearch_mcp.tools.agent_map_async", new_callable=AsyncMock
318318
) as mock_op,
319319
patch(
320-
"futuresearch_mcp.tools.create_session",
320+
"futuresearch_mcp.tools.create_linked_session",
321321
return_value=_make_async_context_manager(mock_session),
322322
),
323323
):
@@ -356,7 +356,7 @@ async def test_submit_returns_task_id(self):
356356
"futuresearch_mcp.tools.single_agent_async", new_callable=AsyncMock
357357
) as mock_op,
358358
patch(
359-
"futuresearch_mcp.tools.create_session",
359+
"futuresearch_mcp.tools.create_linked_session",
360360
return_value=_make_async_context_manager(mock_session),
361361
),
362362
):
@@ -386,7 +386,7 @@ async def test_submit_with_input_data(self):
386386
"futuresearch_mcp.tools.single_agent_async", new_callable=AsyncMock
387387
) as mock_op,
388388
patch(
389-
"futuresearch_mcp.tools.create_session",
389+
"futuresearch_mcp.tools.create_linked_session",
390390
return_value=_make_async_context_manager(mock_session),
391391
),
392392
):
@@ -421,7 +421,7 @@ async def test_submit_with_response_schema(self):
421421
"futuresearch_mcp.tools.single_agent_async", new_callable=AsyncMock
422422
) as mock_op,
423423
patch(
424-
"futuresearch_mcp.tools.create_session",
424+
"futuresearch_mcp.tools.create_linked_session",
425425
return_value=_make_async_context_manager(mock_session),
426426
),
427427
):
@@ -1023,7 +1023,7 @@ async def test_submit_with_inline_data(self):
10231023
"futuresearch_mcp.tools.agent_map_async", new_callable=AsyncMock
10241024
) as mock_op,
10251025
patch(
1026-
"futuresearch_mcp.tools.create_session",
1026+
"futuresearch_mcp.tools.create_linked_session",
10271027
return_value=_make_async_context_manager(mock_session),
10281028
),
10291029
):
@@ -1062,7 +1062,7 @@ async def test_submit_with_artifact_id(self):
10621062
"futuresearch_mcp.tools.agent_map_async", new_callable=AsyncMock
10631063
) as mock_op,
10641064
patch(
1065-
"futuresearch_mcp.tools.create_session",
1065+
"futuresearch_mcp.tools.create_linked_session",
10661066
return_value=_make_async_context_manager(mock_session),
10671067
),
10681068
):
@@ -1197,7 +1197,7 @@ async def test_upload_from_url(self):
11971197
return_value=mock_df,
11981198
),
11991199
patch(
1200-
"futuresearch_mcp.tools.create_session",
1200+
"futuresearch_mcp.tools.create_linked_session",
12011201
return_value=_make_async_context_manager(mock_session),
12021202
),
12031203
patch(
@@ -1235,7 +1235,7 @@ async def test_upload_from_local_path(self, tmp_path: Path):
12351235

12361236
with (
12371237
patch(
1238-
"futuresearch_mcp.tools.create_session",
1238+
"futuresearch_mcp.tools.create_linked_session",
12391239
return_value=_make_async_context_manager(mock_session),
12401240
),
12411241
patch(
@@ -1296,7 +1296,7 @@ async def test_upload_from_url_http_mode_registers_poll_token(self):
12961296
return_value=mock_df,
12971297
),
12981298
patch(
1299-
"futuresearch_mcp.tools.create_session",
1299+
"futuresearch_mcp.tools.create_linked_session",
13001300
return_value=_make_async_context_manager(mock_session),
13011301
),
13021302
patch(
@@ -1476,7 +1476,7 @@ async def test_submit_stdio_returns_single_content(self):
14761476
"futuresearch_mcp.tools.agent_map_async", new_callable=AsyncMock
14771477
) as mock_op,
14781478
patch(
1479-
"futuresearch_mcp.tools.create_session",
1479+
"futuresearch_mcp.tools.create_linked_session",
14801480
return_value=_make_async_context_manager(mock_session),
14811481
),
14821482
):
@@ -1507,7 +1507,7 @@ async def test_submit_http_returns_widget_and_text(self, fake_redis):
15071507
"futuresearch_mcp.tools.agent_map_async", new_callable=AsyncMock
15081508
) as mock_op,
15091509
patch(
1510-
"futuresearch_mcp.tools.create_session",
1510+
"futuresearch_mcp.tools.create_linked_session",
15111511
return_value=_make_async_context_manager(mock_session),
15121512
),
15131513
patch.object(redis_store, "get_redis_client", return_value=fake_redis),
@@ -1737,7 +1737,7 @@ async def test_agent_passes_session_params(self):
17371737
patch(
17381738
"futuresearch_mcp.tools.agent_map_async", new_callable=AsyncMock
17391739
) as mock_op,
1740-
patch("futuresearch_mcp.tools.create_session") as mock_cs,
1740+
patch("futuresearch_mcp.tools.create_linked_session") as mock_cs,
17411741
):
17421742
mock_cs.return_value = _make_async_context_manager(mock_session)
17431743
mock_op.return_value = mock_task
@@ -1767,7 +1767,7 @@ async def test_agent_passes_session_name(self):
17671767
patch(
17681768
"futuresearch_mcp.tools.agent_map_async", new_callable=AsyncMock
17691769
) as mock_op,
1770-
patch("futuresearch_mcp.tools.create_session") as mock_cs,
1770+
patch("futuresearch_mcp.tools.create_linked_session") as mock_cs,
17711771
):
17721772
mock_cs.return_value = _make_async_context_manager(mock_session)
17731773
mock_op.return_value = mock_task
@@ -1805,7 +1805,7 @@ async def test_upload_data_passes_session_id(self):
18051805
new_callable=AsyncMock,
18061806
return_value=mock_df,
18071807
),
1808-
patch("futuresearch_mcp.tools.create_session") as mock_cs,
1808+
patch("futuresearch_mcp.tools.create_linked_session") as mock_cs,
18091809
patch(
18101810
"futuresearch_mcp.tools.create_table_artifact",
18111811
new_callable=AsyncMock,
@@ -1840,7 +1840,7 @@ async def test_response_includes_session_id(self):
18401840
"futuresearch_mcp.tools.agent_map_async", new_callable=AsyncMock
18411841
) as mock_op,
18421842
patch(
1843-
"futuresearch_mcp.tools.create_session",
1843+
"futuresearch_mcp.tools.create_linked_session",
18441844
return_value=_make_async_context_manager(mock_session),
18451845
),
18461846
):
@@ -1884,7 +1884,7 @@ async def test_use_list_stdio_saves_csv(self, tmp_path, monkeypatch):
18841884
return_value=mock_result,
18851885
),
18861886
patch(
1887-
"futuresearch_mcp.tools.create_session",
1887+
"futuresearch_mcp.tools.create_linked_session",
18881888
return_value=_make_async_context_manager(mock_session),
18891889
),
18901890
patch(
@@ -1931,7 +1931,7 @@ async def test_use_list_http_no_csv(self, tmp_path, monkeypatch):
19311931
return_value=mock_result,
19321932
),
19331933
patch(
1934-
"futuresearch_mcp.tools.create_session",
1934+
"futuresearch_mcp.tools.create_linked_session",
19351935
return_value=_make_async_context_manager(mock_session),
19361936
),
19371937
patch(
@@ -1968,7 +1968,7 @@ async def test_use_list_error_handling(self):
19681968

19691969
with (
19701970
patch(
1971-
"futuresearch_mcp.tools.create_session",
1971+
"futuresearch_mcp.tools.create_linked_session",
19721972
side_effect=EveryrowError("connection failed"),
19731973
),
19741974
):

futuresearch-mcp/tests/test_stdio_content.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ def _submit_patches(mock_op_path: str):
206206
ctx,
207207
patch(mock_op_path, new_callable=AsyncMock, return_value=mock_task),
208208
patch(
209-
"futuresearch_mcp.tools.create_session",
209+
"futuresearch_mcp.tools.create_linked_session",
210210
return_value=_make_async_cm(mock_session),
211211
),
212212
)

0 commit comments

Comments
 (0)