From e0980363ed3b5322ae58eb255a311f74ad59d0a4 Mon Sep 17 00:00:00 2001 From: k3mpaxl <> Date: Tue, 6 Oct 2026 20:51:55 +0200 Subject: [PATCH] Board token: agents need no board ID, changes are attributed to the analyst Agents connected with the token in the X-FactGraph-Token header were asked for the board ID and the token: the server instructions said so, and every agent tool required board_id. The token already names the board (each browser tab makes its own token for each board), so the server now takes the board from it. - Agent tools no longer show board_id; one passed anyway (older prompts) is accepted and must match the token. The full REST-parity profile shows it as optional. A token without an open tab gets a clear 409. - "Session token" is now "board token" in the UI, docs and MCP instructions, and no longer shares a name with Entra session tokens (uti) in the impact analysis. The MCP parameter is board_token; session_token is still accepted but not shown. - Changes an agent makes with an analyst's token are recorded as that analyst's agent: "MCP (Gregor)", "REST (Gregor)" or the agent's own name, "Claude (Gregor)". Notifications and evidence provenance read "Agent via MCP (Gregor)". Trust rules still depend only on the channel. - The connect dialog explains the token and no longer puts a board ID into the suggested prompt. Agent tool budget unchanged (about 9,700 tokens). Co-Authored-By: Claude Opus 5.5 --- README.md | 25 ++++++++++++----------- app/descriptions.py | 2 +- app/extensions.py | 40 ++++++++++++++++++++++++++++--------- app/main.py | 20 ++++++++++++++++--- tests/test_mcp_contracts.py | 5 ++++- tests/test_relay.py | 13 +++++++++++- web/e2e/workflows.spec.ts | 16 ++++++++++----- web/src/App.tsx | 22 ++++++++++---------- web/src/identity.ts | 2 +- web/src/importBatches.ts | 2 +- web/src/notifications.ts | 6 ++++-- web/src/provenance.ts | 39 ++++++++++++++++++++++++++++++++---- web/src/useBoard.ts | 12 ++++++++--- web/tests/explain.cjs | 13 ++++++++++-- web/tests/notifications.cjs | 8 ++++++-- 15 files changed, 168 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index ea08907..9f6c1fb 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ FactGraph is meant to be spun up during an incident, often in a hurry and someti **Access** - The board ID in the URL is a random UUID (122 bits) and acts as the board's access key. Treat a board link like a password and share it only through a trusted channel. -- REST and MCP need the **session token** of a browser tab that has the board open. Tokens are random per tab, only valid while that tab is connected, accepted **only in the `X-FactGraph-Token` header** (never in URLs, so they do not end up in proxy or platform logs) and compared in constant time. +- REST and MCP need the **board token** of a browser tab that has the board open. Tokens are random per tab and board, only valid while that tab is connected, accepted **only in the `X-FactGraph-Token` header** (never in URLs, so they do not end up in proxy or platform logs) and compared in constant time. - The WebSocket relay accepts browsers only from pages served by FactGraph itself (Origin check against cross-site WebSocket hijacking). Identity and token are sent in the first message, not in the connection URL. **Browser hardening (HTTP headers)** @@ -108,9 +108,9 @@ FactGraph is meant to be spun up during an incident, often in a hurry and someti **What it does not do** - There is no user authentication. Anyone with network access and a board link has full access to that board, so restrict network access to the response team. - Board data lives in the analysts' browsers (IndexedDB). Use trusted devices, and remember that exported JSON files contain the full investigation. -- A session token proves that a browser tab has the board open, not that a human reviewed something. Technically enforced human approval would require separate permissions. +- A board token proves that a browser tab has the board open, not that a human reviewed something. Technically enforced human approval would require separate permissions. - Provenance labels (*parsed by the import*, *reviewed by …*, *confirmed by an agent*, who marked a compromise) are recorded by the browsers, not signed. Anyone with the board link can connect to the relay and write actions with any author or channel, so treat these labels as reliable within a trusted team, not as proof against a hostile participant. The relay operator can also see board content in transit (it stores none). -- Agents share the tab's session token. Only the tab that started a file upload treats its rows as the analyst's own import; the same upload by anything else (an agent, a script) is handled like any other REST write, so its evidence starts unconfirmed. +- Agents share the tab's board token, and their changes are recorded as that analyst's agent ("MCP (Gregor)"). Only the tab that started a file upload treats its rows as the analyst's own import; the same upload by anything else (an agent, a script) is handled like any other REST write, so its evidence starts unconfirmed. - **Remove board from this browser** (board menu) deletes a board's data from this browser's storage; colleagues keep their copies. ## Deploying on Azure App Service @@ -286,14 +286,14 @@ In the graph, failed attempts are hidden by default and locations show as a flag ## Connecting AI agents (MCP and REST) -When a board is opened, the browser creates a random **session token**. REST and MCP expect it in the `X-FactGraph-Token` header (MCP also accepts the `session_token` parameter). The token binds calls to this browser tab; it is not a user account. A new tab or browser creates a new token. +When a board is opened, the browser creates a random **board token**: one per tab and board, valid while that tab has the board open (reloads keep it). REST and MCP expect it in the `X-FactGraph-Token` header (MCP also accepts the `board_token` parameter; the former name `session_token` still works). The token binds calls to this browser tab and its analyst; it is not a user account. A new tab or browser creates a new token. Changes an agent makes with your token are recorded as yours: the change log, notifications and evidence show *MCP (Gregor)* or *REST (Gregor)*, or the agent's own name, e.g. *Claude (Gregor)*. **Connect an agent** (API/MCP menu or command palette) provides ready-to-copy snippets: - `.vscode/mcp.json` for VS Code / GitHub Copilot. By default VS Code asks for the token when the server starts, so the file can be committed; optionally with the token embedded. - Two commands for Claude Code: one with the token for your own configuration, and one with `--scope project` that stores the placeholder `${FACTGRAPH_TOKEN}` in `.mcp.json` (safe to commit; each analyst exports the variable). - Endpoint, header, a paginated REST example (`/entities?q=…&limit=50`) and what the error codes mean for other clients. -- A first message for the agent containing the board ID; it asks the agent to look things up with `find_entities` instead of reading the whole board. +- A first message for the agent; it asks the agent to look things up with `find_entities` instead of reading the whole board. No board ID is needed: the token names the board. ![Connect an agent](docs/images/connect-agent.png) @@ -303,6 +303,8 @@ The repository also contains [.vscode/mcp.json](.vscode/mcp.json); in VS Code ru By default the server exposes a **compact agent profile with 27 tools** (about 9,700 tokens of tool descriptions). Fewer tools mean less context usage and better tool choice. +The board token in the `X-FactGraph-Token` header names the board (every browser tab makes its own token for each board), so agent tools take no board ID and agents never need to ask for one. A `board_id` passed anyway is accepted and must match the token. Clients that cannot send headers pass `board_token` in each call; the full profile also shows `board_id` as an optional parameter. + | Task | MCP tool | REST (relative to `/api/boards/{id}`) | | --- | --- | --- | | Read the graph, find entities | `get_graph`, `find_entities` | `GET /graph`, `GET /entities?q=` | @@ -326,7 +328,7 @@ On connect the server sends binding **working rules** to the agent (also availab ### REST examples ```bash -TOKEN=... # from "Connect an agent" or "Copy session token" +TOKEN=... # board token, from "Connect an agent" or "Copy board token" BOARD=... # board UUID curl -X POST "http://127.0.0.1:8080/api/boards/$BOARD/entities" \ @@ -342,16 +344,15 @@ curl -X POST "http://127.0.0.1:8080/api/boards/$BOARD/activities" \ "observation":"SecretList from 203.0.113.7 as sp-deploy-prod","locator":"CorrelationId=7f3a"}' ``` -Python with FastMCP: +Python with FastMCP (the token in the header names the board): ```python from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport -async with Client("http://127.0.0.1:8080/mcp/") as client: - result = await client.call_tool("create_entity", { - "board_id": "BOARD_UUID", "session_token": "TOKEN", - "body": {"name": "Azure credential", "kind": "Credential"}, - }) +transport = StreamableHttpTransport("http://127.0.0.1:8080/mcp/", headers={"X-FactGraph-Token": "TOKEN"}) +async with Client(transport) as client: + result = await client.call_tool("create_entity", {"body": {"name": "Azure credential", "kind": "Credential"}}) print(result.data) ``` diff --git a/app/descriptions.py b/app/descriptions.py index 09f084c..554ccf0 100644 --- a/app/descriptions.py +++ b/app/descriptions.py @@ -71,7 +71,7 @@ def tool_description(route: APIRoute) -> str: method = next(iter(route.methods)) notes = [f'REST equivalent: {method} {route.path}.'] if any(p.name == 'board_id' for p in route.dependant.path_params): - notes.append('The board must be open in a browser; authenticate with session_token or the X-FactGraph-Token header.') + notes.append('The board must be open in a browser. With the board token in the X-FactGraph-Token header, leave board_id and board_token empty: the token names the board.') if method == 'PATCH': notes.append('Omitted fields stay unchanged; explicit null clears nullable fields.') return f"{purpose(route)}\n\n{' '.join(notes)}" diff --git a/app/extensions.py b/app/extensions.py index c8e6518..f189f00 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -2,10 +2,11 @@ from __future__ import annotations import inspect -from typing import Literal, get_type_hints +from typing import Annotated, Literal, get_type_hints from uuid import uuid4 from fastapi import HTTPException from fastapi.routing import APIRoute +from fastmcp.tools import FunctionTool from pydantic import Field, ValidationError from app.contracts import LAYER_HELP, Layer, StrictModel from app.descriptions import describe_routes, purpose, tool_description @@ -237,11 +238,11 @@ async def delete_type(board_id: str, type_id: str): continue method = next(iter(route.methods)) name = route.endpoint.__name__ - full_names.add(register_mcp_adapter(mcp, route, core['run_mcp_session'], annotations=annotations_for(method, name))) + full_names.add(register_mcp_adapter(mcp, route, core['run_mcp_session'], core['board_of_session'], annotations=annotations_for(method, name))) if name in AGENT_TOOLS: tool_name, purpose_text = AGENT_TOOLS[name] - agent_names.add(register_mcp_adapter(mcp, route, core['run_mcp_session'], name=tool_name, - description=purpose_text or purpose(route), annotations=annotations_for(method, name))) + agent_names.add(register_mcp_adapter(mcp, route, core['run_mcp_session'], core['board_of_session'], name=tool_name, + description=purpose_text or purpose(route), annotations=annotations_for(method, name), show_board=False)) missing = set(AGENT_TOOLS) - {r.endpoint.__name__ for r in app.routes if isinstance(r, APIRoute)} if missing: raise RuntimeError(f'Agent tool profile references unknown REST operations: {missing}') @@ -249,22 +250,39 @@ async def delete_type(board_id: str, type_id: str): core['MCP_TOOL_NAMES'] = {'agent': agent_names, 'full': full_names} -def register_mcp_adapter(mcp, route, run_session, *, name: str | None = None, description: str | None = None, annotations=None) -> str: +# The board token names the board (each browser tab makes its own token for each board it opens), so agents connected +# with it in the X-FactGraph-Token header leave both empty; passing them stays possible for clients that cannot send headers. +BOARD_ID = Annotated[str | None, Field(description='Leave empty: the board of your board token.')] +BOARD_TOKEN = Annotated[str | None, Field(description='Omit if the header is set.')] + + +def register_mcp_adapter(mcp, route, run_session, board_of_session, *, name: str | None = None, description: str | None = None, annotations=None, + show_board: bool = True) -> str: + """An MCP tool for a REST operation. show_board=False (the agent profile) leaves board_id out of the schema agents see, + so they never ask for it; a board_id passed anyway (older prompts) is still accepted and checked against the token.""" endpoint = route.endpoint signature = inspect.signature(endpoint) hints = get_type_hints(endpoint) - parameters = [p.replace(annotation=hints.get(p.name, p.annotation)) for p in signature.parameters.values()] + parameters = [p.replace(annotation=BOARD_ID, default=None) if p.name == 'board_id' else p.replace(annotation=hints.get(p.name, p.annotation)) + for p in signature.parameters.values()] + parameters.append(inspect.Parameter('board_token', inspect.Parameter.KEYWORD_ONLY, default=None, annotation=BOARD_TOKEN)) + # The former name, still accepted from older clients, no longer shown. parameters.append(inspect.Parameter('session_token', inspect.Parameter.KEYWORD_ONLY, default=None, annotation=str | None)) parameters = [p.replace(kind=inspect.Parameter.KEYWORD_ONLY) for p in parameters] async def invoke(**kwargs): - token = kwargs.pop('session_token', None) + token = kwargs.pop('board_token', None) or kwargs.pop('session_token', None) + kwargs.pop('session_token', None) try: for name, annotation in hints.items(): if name in kwargs and inspect.isclass(annotation) and issubclass(annotation, StrictModel) and isinstance(kwargs[name], dict): kwargs[name] = annotation.model_validate(kwargs[name]) if 'board_id' in signature.parameters: - return await run_session(token, lambda: endpoint(**kwargs)) + async def run(): + if not kwargs.get('board_id'): + kwargs['board_id'] = await board_of_session() + return await endpoint(**kwargs) + return await run_session(token, run) result = endpoint(**kwargs) return await result if inspect.isawaitable(result) else result except HTTPException as exc: @@ -277,5 +295,9 @@ async def invoke(**kwargs): invoke.__signature__ = signature.replace(parameters=parameters, return_annotation=dict) invoke.__name__ = name or 'rest_' + endpoint.__name__ invoke.__doc__ = description or tool_description(route) - mcp.tool(invoke, name=invoke.__name__, annotations=annotations) + tool = FunctionTool.from_function(invoke, name=invoke.__name__, annotations=annotations) + # Only the advertised schema: arguments are validated against the function, which still accepts what is left out. + hidden = {'session_token'} | (set() if show_board else {'board_id'}) + tool.parameters = {**tool.parameters, 'properties': {key: value for key, value in tool.parameters.get('properties', {}).items() if key not in hidden}} + mcp.add_tool(tool) return invoke.__name__ diff --git a/app/main.py b/app/main.py index 92e936d..2eaeb38 100644 --- a/app/main.py +++ b/app/main.py @@ -35,7 +35,7 @@ WEB_DIR = Path(__file__).resolve().parents[1] / "web" / "dist" MCP_INSTRUCTIONS = """FactGraph is an evidence-first investigation graph that analysts and agents build together. -Connection: every tool needs board_id and the board open in a browser (data lives in the browser; the server only relays). Pass the board's session token as session_token or header X-FactGraph-Token. HTTP 409 "offline" means nobody has the board open: ask the analyst to open it. There is no board listing; the analyst gives you board ID and token (plug icon in the board header → Connect an agent). +Connection: you work on one board, chosen by the analyst's board token in the X-FactGraph-Token header (set up with the plug icon in the board header → Connect an agent). Leave board_id and board_token empty: the server takes the board from the token. Never ask the analyst for a board ID or a token. The data lives in the analyst's open browser tab (the server only relays): HTTP 409 means that tab is closed; ask the analyst to open the board again. Only clients that cannot send headers pass board_token in each call. Your changes are recorded as the analyst's agent, e.g. "MCP (Gregor)". Workflow 1. Read first: find_entities (q = name or identifier) or get_graph, and reuse existing IDs. Never create a second entity for the same object; merge_entities fixes duplicates. @@ -230,7 +230,7 @@ async def browser_command(board_id: str, operation: str, **values) -> dict: raise HTTPException(403, "Token is not connected to this open board") actor, peer = matching[0] else: - raise HTTPException(401, "Session token is required") + raise HTTPException(401, "Board token is required (X-FactGraph-Token header)") future: asyncio.Future[dict] = loop.create_future() pending[request_id] = (peer.websocket, future) try: @@ -753,10 +753,24 @@ async def import_activity_rows(board_id: str, body): return await globals()["import_activity_rows_impl"](board_id, body) +async def board_of_session() -> str: + """The board of the current board token. Every browser tab makes its own token for each board it opens, so the + token names the board: agents connected with it never need the board ID.""" + token = request_token.get() + async with rooms_lock: + boards = {board for board, room in rooms.items() if any(token_matches(peer.session_token, token) for peer in room.values())} + if len(boards) == 1: + return boards.pop() + if not boards: + raise HTTPException(409, "No open board for this board token: ask the analyst to open the board in the browser " + "(plug icon → Connect an agent shows the current token)") + raise HTTPException(422, "This board token is connected to several boards; pass board_id") + + async def run_mcp_session(session_token: str | None, operation): effective_token = session_token or request_token.get() if not effective_token: - raise HTTPException(401, "MCP requires X-FactGraph-Token or session_token") + raise HTTPException(401, "MCP requires the board token in the X-FactGraph-Token header (or board_token)") token_context = request_token.set(effective_token) channel_context = request_channel.set("MCP") try: diff --git a/tests/test_mcp_contracts.py b/tests/test_mcp_contracts.py index 20da73e..bd06833 100644 --- a/tests/test_mcp_contracts.py +++ b/tests/test_mcp_contracts.py @@ -73,6 +73,9 @@ async def test_default_agent_profile_is_small_and_explained(self): self.assertTrue(by_name['find_entities'].annotations.readOnlyHint) self.assertTrue(by_name['delete_entity'].annotations.destructiveHint) self.assertIn('role', json.dumps(by_name['create_activity'].input_schema)) + # The board token names the board: agents are never asked for a board ID. + self.assertFalse([t.name for t in tools if 'board_id' in t.input_schema.get('properties', {})]) + self.assertFalse([t.name for t in tools if 'board_id' in (t.input_schema.get('required') or [])]) async def test_full_profile_describes_every_tool(self): set_mcp_tool_profile('full') @@ -88,5 +91,5 @@ async def test_full_profile_describes_every_tool(self): def test_instructions_cover_workflow(self): from app.main import MCP_INSTRUCTIONS - for phrase in ('find_entities', 'create_activity', 'unconfirmed', 'review_evidence', 'review_note', 'create_group', 'dry_run', 'session_token', '409', 'FACTGRAPH_MCP_TOOLS'): + for phrase in ('find_entities', 'create_activity', 'unconfirmed', 'review_evidence', 'review_note', 'create_group', 'dry_run', 'board_token', '409', 'FACTGRAPH_MCP_TOOLS'): self.assertIn(phrase, MCP_INSTRUCTIONS) diff --git a/tests/test_relay.py b/tests/test_relay.py index baf7334..1b64a7f 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -151,7 +151,10 @@ async def test_mcp_header_token_reads_and_writes_board(self): headers={"X-FactGraph-Token": token}, ) async with Client(transport) as client: - graph_task = asyncio.create_task(client.call_tool("get_graph", {"board_id": board})) + # The token in the header names the board: agents pass no board_id (the agent tools do not even show it). + tools = {tool.name: tool for tool in await client.list_tools()} + self.assertNotIn("board_id", tools["get_graph"].input_schema["properties"]) + graph_task = asyncio.create_task(client.call_tool("get_graph", {})) command = await receive(websocket) self.assertEqual(command["operation"], "snapshot") await websocket.send(json.dumps({ @@ -162,6 +165,7 @@ async def test_mcp_header_token_reads_and_writes_board(self): graph = await graph_task self.assertEqual(graph.data["board_id"], board) + # A board_id passed anyway (older prompts) is still accepted. entity_task = asyncio.create_task(client.call_tool("create_entity", { "board_id": board, "body": {"name": "MCP entity", "kind": "Test"}, })) @@ -196,6 +200,13 @@ async def test_mcp_header_token_reads_and_writes_board(self): })) self.assertEqual((await delete_task).data["accepted_actions"], 1) + # A token whose browser tab is not open names no board: a clear answer instead of a question about IDs. + stranger = StreamableHttpTransport(f"http://127.0.0.1:{self.port}/mcp/", headers={"X-FactGraph-Token": "z" * 64}) + async with Client(stranger) as client: + result = await client.call_tool("get_graph", {}, raise_on_error=False) + self.assertTrue(result.is_error) + self.assertIn("No open board for this board token", result.content[0].text) + async def test_rest_patch_evidence_reaches_the_right_board(self): board, actor, token = str(uuid4()), str(uuid4()), "c" * 64 relation_id, evidence_id = str(uuid4()), str(uuid4()) diff --git a/web/e2e/workflows.spec.ts b/web/e2e/workflows.spec.ts index 8a9c5b3..bfe5b6b 100644 --- a/web/e2e/workflows.spec.ts +++ b/web/e2e/workflows.spec.ts @@ -439,7 +439,7 @@ test('large boards: the entity picker tells same-named files apart; merge shows test('trust: a file upload with the shared token is not the analyst\'s import; the UI\'s own upload is',async({page,request})=>{ const {id,token,graph}=await setup(page,request) - // An agent (or script) holding the session token posts a file to the import endpoint. + // An agent (or script) holding the board token posts a file to the import endpoint. const csv='IPAddress,FilePath,TimeGenerated\n198.51.100.9,/srv/app/.env,2026-09-28T10:00:00Z\n' const response=await request.fetch(`${base}/api/boards/${id}/imports/file`,{method:'POST',headers:{'X-FactGraph-Token':token},multipart:{file:{name:'agent.csv',mimeType:'text/csv',buffer:Buffer.from(csv)},title:'Agent rows'}}) expect(response.ok(),await response.text()).toBeTruthy() @@ -598,7 +598,9 @@ test('first visit asks for a name; help explains browser storage; agent snippets await expect(connect.locator('pre').nth(1)).toContainText("--scope project") await expect(connect.locator('pre').nth(1)).toContainText("'X-FactGraph-Token: ${FACTGRAPH_TOKEN}'") await expect(connect.locator('pre').nth(1)).not.toContainText(token) - await expect(connect.getByText(`Board ID: ${id}`)).toBeVisible() + // The token names the board: the suggested first message needs no board ID. + await expect(connect.getByText('connected to my board through my board token')).toBeVisible() + await expect(connect.getByText(`Board ID: ${id}`)).toHaveCount(0) await context.close() }) @@ -689,11 +691,15 @@ test('notifications: agent writes, several log files in a queue, remembered view await expect(bell.locator('.notifications-badge')).toBeVisible() await bell.click() const panel=page.getByRole('dialog',{name:'Notifications'}) - // The agent's three MCP calls are one notice; the REST call is another client. - const mcpNotice=panel.locator('.notice').filter({hasText:'Agent via MCP changed the board'}) + // The agent's three MCP calls are one notice; the REST call is another client. Both used this analyst's board token. + const mcpNotice=panel.locator('.notice').filter({hasText:'Agent via MCP (Test analyst) changed the board'}) await expect(mcpNotice).toHaveCount(1) await expect(mcpNotice).toContainText('1 entity, 1 relationship, 1 new evidence item') - await expect(panel.locator('.notice').filter({hasText:'Agent via REST changed the board'})).toHaveCount(1) + await expect(panel.locator('.notice').filter({hasText:'Agent via REST (Test analyst) changed the board'})).toHaveCount(1) + expect((await graph()).entities.length).toBeGreaterThan(0) + const authors=await page.evaluate(async()=>{const id=location.pathname.split('/').pop();const db:any=await new Promise(r=>{const q=indexedDB.open('factgraph-browser');q.onsuccess=()=>r(q.result)}) + const all:any[]=await new Promise(r=>{const q=db.transaction('actions').objectStore('actions').index('byBoard').getAll(id);q.onsuccess=()=>r(q.result)});return [...new Set(all.filter(a=>a.channel==='MCP'||a.channel==='REST').map(a=>a.author))]}) + expect(authors.sort()).toEqual(['MCP (Test analyst)','REST (Test analyst)']) await panel.getByRole('button',{name:'Review'}).first().click() await expect(page.getByRole('tab',{name:/Evidence review/})).toHaveAttribute('aria-selected','true') await page.getByRole('tab',{name:'Graph'}).click() diff --git a/web/src/App.tsx b/web/src/App.tsx index 7fda21c..7e22ee6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -816,19 +816,20 @@ function HelpDialog({ boardCount, onExport, onClose, initialTab = 'data' }: { bo type AgentClient = 'vscode' | 'claude' | 'other' /** Copy-ready MCP/REST configuration for this board, including the token header. */ -function AgentConnectDialog({ boardId, token, onCopy, onClose }: { boardId: string; token: string; onCopy: (value: string, label: string) => void; onClose: () => void }) { +function AgentConnectDialog({ boardId, token, name, onCopy, onClose }: { boardId: string; token: string; name: string; onCopy: (value: string, label: string) => void; onClose: () => void }) { const [client, setClient] = useState('vscode') const [embed, setEmbed] = useState(false) const origin = window.location.origin const mcpUrl = `${origin}/mcp/` const vscode = JSON.stringify(embed ? { servers: { factgraph: { type: 'http', url: mcpUrl, headers: { 'X-FactGraph-Token': token } } } } - : { inputs: [{ type: 'promptString', id: 'factgraph-token', description: 'FactGraph session token (header: plug icon → Copy session token)', password: true }], + : { inputs: [{ type: 'promptString', id: 'factgraph-token', description: 'FactGraph board token (plug icon → Copy board token)', password: true }], servers: { factgraph: { type: 'http', url: mcpUrl, headers: { 'X-FactGraph-Token': '${input:factgraph-token}' } } } }, null, 2) const claude = `claude mcp add --transport http factgraph ${mcpUrl} --header "X-FactGraph-Token: ${token}"` // Single quotes keep the variable unexpanded: .mcp.json stores the placeholder and Claude Code reads the token at start. const claudeShared = `claude mcp add --scope project --transport http factgraph ${mcpUrl} --header 'X-FactGraph-Token: \${FACTGRAPH_TOKEN}'` - const prompt = `Use the FactGraph MCP server for this investigation. Board ID: ${boardId}. Before adding anything, look up what exists with find_entities (q = a name or identifier, paginate with offset/limit) and get_impact; read the whole board with get_graph only when it is small.` + // The token in the MCP configuration names the board: the prompt needs no board ID. + const prompt = `Use the FactGraph MCP server for this investigation; it is connected to my board through my board token. Before adding anything, look up what exists with find_entities (q = a name or identifier, paginate with offset/limit) and get_impact; read the whole board with get_graph only when it is small.` // A targeted, paginated read: large boards are thousands of records, a full graph read is megabytes. const curl = `curl -s "${origin}/api/boards/${boardId}/entities?q=203.0.113.7&limit=50" -H "X-FactGraph-Token: ${token}"` const Snippet = ({ label, value, hint }: { label: string; value: string; hint?: string }) =>
@@ -841,10 +842,11 @@ function AgentConnectDialog({ boardId, token, onCopy, onClose }: { boardId: stri {([['vscode', 'VS Code / Copilot'], ['claude', 'Claude Code'], ['other', 'Other / REST']] as [AgentClient, string][]).map(([id, label]) => )}
+

Your board token connects an agent to this board as you: it is valid while this tab has the board open, a colleague has their own, and the agent needs no board ID. Its changes appear as yours, for example “MCP ({name})”.

{client === 'vscode' && <> - +

Then run MCP: List Servers → factgraph → Start, and pick the FactGraph tools in the agent tool picker.

} {client === 'claude' && <> @@ -854,12 +856,12 @@ function AgentConnectDialog({ boardId, token, onCopy, onClose }: { boardId: stri } {client === 'other' && <> - +
When a call fails
-
401 / 403
Token missing or wrong: copy it again (plug icon → Copy session token). It changes when this tab is reloaded in a new session.
+
401 / 403
Token missing or wrong: copy it again (plug icon → Copy board token). A new tab or browser has its own token.
404
Wrong board ID, or the record no longer exists: list again with q.
-
409 “Board offline”
No browser has the board open: the data lives in browsers, keep this tab open while agents work.
+
409 “Board offline”, “No open board for this board token”
No browser has the board open, or not this tab: the data lives in browsers, keep this tab open while agents work.
409 “changed”
Someone changed the record meanwhile: read it again and retry with the new revision.
503
The browser connection dropped during the call: retry; repeated IDs are not stored twice.
} @@ -1457,7 +1459,7 @@ export default function App() { { id: 'b-export', group: 'Board', label: 'Export board JSON', icon: , run: download }, { id: 'b-import', group: 'Board', label: 'Import board JSON…', icon: , run: () => importInput.current?.click() }, { id: 'b-link', group: 'Board', label: 'Copy board link', icon: , run: () => void copyValue(window.location.href, 'Board link') }, - { id: 'b-token', group: 'Board', label: 'Copy session token (REST + MCP)', icon: , run: () => void copyValue(board.sessionToken, 'Session token') }, + { id: 'b-token', group: 'Board', label: 'Copy board token (REST + MCP)', icon: , run: () => void copyValue(board.sessionToken, 'Session token') }, { id: 'b-connect', group: 'Board', label: 'Connect an agent (VS Code, Claude Code)…', icon: , run: () => setShowConnect(true) }, { id: 'b-help', group: 'Board', label: 'Keyboard & mouse controls', hint: '?', icon: , run: () => setShowHelp('keys') }, { id: 'b-glossary', group: 'Board', label: 'Glossary: status, evidence, review…', icon: , run: () => setShowHelp('glossary') }, @@ -1536,7 +1538,7 @@ export default function App() { - + REST documentation

Same token for REST + MCP. Keep this board open while agents write.

@@ -1607,7 +1609,7 @@ export default function App() { kinds={[...new Set([...kinds, ...(data?.entity_types ?? []).map(type => type.name), ...(data?.entities ?? []).map(entity => entity.kind)])]} onClose={() => { setShowLogImport(false); setImportRequest(null) }} onApply={runImports} />} {showPalette && setShowPalette(false)} />} - {showConnect && void copyValue(value, label)} onClose={() => setShowConnect(false)} />} + {showConnect && void copyValue(value, label)} onClose={() => setShowConnect(false)} />} {askName && board.ready && !showHelp && { board.setName(name); setAskName(false) }} onLearnMore={() => setShowHelp('data')} />} {showHelp && setShowHelp(false)} />} { const file = event.target.files?.[0]; event.target.value = ''; void importFile(file) }} /> diff --git a/web/src/identity.ts b/web/src/identity.ts index 123621d..78efd9b 100644 --- a/web/src/identity.ts +++ b/web/src/identity.ts @@ -1,7 +1,7 @@ import { uuid } from './uuid' /** - * A duplicated browser tab inherits sessionStorage, and with it the actor ID and session token of the original tab. Two + * A duplicated browser tab inherits sessionStorage, and with it the actor ID and board token of the original tab. Two * tabs with one actor make the relay replace one socket with the other in turn, and their actions share one identity. * Tabs that hold an actor answer a probe on a BroadcastChannel, so a duplicate takes a fresh identity before it connects. */ diff --git a/web/src/importBatches.ts b/web/src/importBatches.ts index 1b75147..f28c06b 100644 --- a/web/src/importBatches.ts +++ b/web/src/importBatches.ts @@ -2,7 +2,7 @@ import type { ActionDraft } from './board' /** * Change batches of this tab's own file uploads. The server marks every upload to /imports/file as channel "Import", but - * the session token that authorises it is also the agents' token: only the tab that started an upload knows its batch. + * the board token that authorises it is also the agents' token: only the tab that started an upload knows its batch. */ export const ownImportBatches = new Set() diff --git a/web/src/notifications.ts b/web/src/notifications.ts index 956c50c..78bafcc 100644 --- a/web/src/notifications.ts +++ b/web/src/notifications.ts @@ -1,4 +1,5 @@ import type { BoardAction } from './board' +import { agentText } from './provenance' /** * Notifications: what agents, Enricher and colleagues changed on the board, the analyst's own imports and exports, @@ -53,9 +54,10 @@ export function classify(actions: BoardAction[], me: string): { kind: 'agent' | if (!first) return null if (actions.every(a => a.actor === me && (a.channel ?? 'UI') === 'UI')) return null // Enricher was called GTIEnricher before its 0.2 release; its earlier exports carry that name. - if (actions.some(a => a.author === 'Enricher' || a.author === 'GTIEnricher')) return { kind: 'enricher', author: 'Enricher' } + if (actions.some(a => /^(GTI)?Enricher\b/.test(a.author ?? ''))) return { kind: 'enricher', author: 'Enricher' } const channel = first.channel ?? 'UI' - if (channel === 'MCP' || channel === 'REST') return { kind: 'agent', author: first.author && first.author !== channel ? `${first.author} (${channel})` : `Agent via ${channel}` } + // "Agent via MCP (Gregor)": an agent working with Gregor's board token. + if (channel === 'MCP' || channel === 'REST') return { kind: 'agent', author: agentText(first.author, channel) } return { kind: 'colleague', author: first.author || 'A colleague' } } diff --git a/web/src/provenance.ts b/web/src/provenance.ts index 7f49fe1..8fc78e0 100644 --- a/web/src/provenance.ts +++ b/web/src/provenance.ts @@ -18,20 +18,51 @@ export const REVIEW_LABEL: Record = { import: 'Parsed by the import', analyst: 'Reviewed by an analyst', agent: 'Confirmed by an agent', open: 'To review', } -/** "Reviewed by Jo Doe", "Parsed by the import", "Confirmed by Claude via MCP". */ +const AGENT_CHANNELS = ['REST', 'MCP'] + +/** + * The author of a change an agent made through REST or MCP: the agent's own name, or the channel, and in brackets the + * analyst whose board token it used ("MCP (Gregor)", "Claude (Gregor)"). The browser that applies the change knows its + * analyst; the board token belongs to that tab. + */ +export function onBehalf(author: string | null | undefined, channel: string, analyst: string): string { + const base = !author || author === 'API' ? channel : author + return !analyst || base.endsWith(`(${analyst})`) ? base : `${base} (${analyst})` +} + +/** + * Such an author for people: "MCP (Gregor)" → "Agent via MCP (Gregor)", "Claude (Gregor)" over MCP → "Claude via MCP + * (Gregor)". Changes from before the analyst was recorded ("MCP", "Claude") read "Agent via MCP", "Claude via MCP". + */ +export function agentText(author: string | null | undefined, channel?: string | null): string { + const match = /^(.*?)(?: \(([^()]+)\))?$/.exec((author ?? '').trim())! + let name = match[1].trim() + const analyst = match[2] + const via = channel && AGENT_CHANNELS.includes(channel) ? channel : AGENT_CHANNELS.includes(name) ? name : null + if (!name || name === via || name === 'API') name = 'Agent' + return `${name}${via ? ` via ${via}` : ''}${analyst ? ` (${analyst})` : ''}` +} + +/** "Reviewed by Jo Doe", "Parsed by the import", "Confirmed by an agent via MCP (Gregor)". */ export function reviewText(item: Assertion): string { const kind = reviewKind(item) if (kind === 'analyst') return item.reviewer_name ? `Reviewed by ${item.reviewer_name}` : REVIEW_LABEL.analyst - if (kind === 'agent') return `Confirmed by ${item.reviewer_name || 'an agent'} via REST/MCP, not checked by an analyst` + if (kind === 'agent') { + const who = agentText(item.reviewer_name) + return `Confirmed by ${who.replace(/^Agent\b/, 'an agent')}${who.includes(' via ') ? '' : ' via REST/MCP'}, not checked by an analyst` + } return REVIEW_LABEL[kind] } -/** "File import", "Jo Doe (UI)", "Claude (MCP)". */ +/** "File import by an analyst", "Jo Doe by hand", "Agent via MCP (Gregor)", "Import by an agent via REST (Gregor)". */ export function addedText(item: Assertion): string { const via = item.created_via ?? null, by = item.created_by ?? null if (!via && !by) return 'Unknown (added before this was recorded)' if (by === 'Import' && (via === 'Import' || via === 'UI' || !via)) return 'File import by an analyst' - if (via === 'REST' || via === 'MCP') return `${by && by !== via && by !== 'Import' ? by : 'An agent'} via ${via}${by === 'Import' ? ' (import)' : ''}` + if (via === 'REST' || via === 'MCP') { + const who = agentText(by, via) + return /^Import\b/.test(who) ? who.replace(/^Import/, 'Import by an agent') : who + } return `${by ?? 'Someone'} by hand` } diff --git a/web/src/useBoard.ts b/web/src/useBoard.ts index d19df37..2539cb9 100644 --- a/web/src/useBoard.ts +++ b/web/src/useBoard.ts @@ -7,6 +7,7 @@ import { placeNew } from './layout' import { actionChunks, messageParts, queryRecords, COMPRESS_ABOVE, COMPRESSED_RAW_LIMIT, RELAY_LIMIT, gzipText, packFrame, unpackFrame } from './query' import { uuid } from './uuid' import { trustedDrafts } from './importBatches' +import { onBehalf } from './provenance' import { actorTaken, freshIdentity, holdActor, releaseActor, storedIdentity } from './identity' import { isSummary, summarize, syncPlan } from './sync' import { validateDrafts } from './validation' @@ -213,14 +214,16 @@ export function useBoard(boardId: string, listener?: ChangeListener) { const undo = useCallback(async (scope: UndoScope = 'analyst') => { const targets = undoTargets(actionsRef.current, actor, scope) if (!targets) return false - await emitMany([{ type: 'action.undo', payload: { action_ids: targets.map(item => item.id) }, channel: scope === 'agent' ? 'REST' : 'UI' }]) + await emitMany([{ type: 'action.undo', payload: { action_ids: targets.map(item => item.id) }, channel: scope === 'agent' ? 'REST' : 'UI', + ...(scope === 'agent' ? { author: onBehalf(null, 'REST', nameRef.current) } : {}) }]) return true }, [actor, emitMany]) const redo = useCallback(async (scope: UndoScope = 'analyst') => { const target = redoTarget(actionsRef.current, actor, scope) if (!target) return false - await emitMany([{ type: 'action.redo', payload: target.payload, channel: scope === 'agent' ? 'REST' : 'UI' }]) + await emitMany([{ type: 'action.redo', payload: target.payload, channel: scope === 'agent' ? 'REST' : 'UI', + ...(scope === 'agent' ? { author: onBehalf(null, 'REST', nameRef.current) } : {}) }]) return true }, [actor, emitMany]) @@ -350,7 +353,10 @@ export function useBoard(boardId: string, listener?: ChangeListener) { } }) } else if (message.operation === 'apply' && Array.isArray(message.drafts) && message.drafts.length <= 200 && message.drafts.every(isDraft)) { - const accepted = await emitMany(trustedDrafts(message.drafts), message.deferRender === true) + // An agent works with this tab's board token: its changes are recorded as "MCP (Gregor)", "Claude (Gregor)". + const drafts = trustedDrafts(message.drafts).map(draft => draft.channel === 'MCP' || draft.channel === 'REST' + ? { ...draft, author: onBehalf(draft.author, draft.channel, nameRef.current) } : draft) + const accepted = await emitMany(drafts, message.deferRender === true) reply(requestId, { ok: true, accepted }) } else reply(requestId, { ok: false, error: 'Invalid API request' }) } catch (error) { diff --git a/web/tests/explain.cjs b/web/tests/explain.cjs index 89263bc..44d6b0e 100644 --- a/web/tests/explain.cjs +++ b/web/tests/explain.cjs @@ -34,7 +34,7 @@ console.log('Explanations: truth reasons match the projection, glossary complete // The original row: which column the event time came from; key fields first; who confirmed what. { const { timeSourceOf, orderedKeys } = require(dir + '/rows.js') - const { reviewKind, reviewText, addedText, reviewCounts } = require(dir + '/provenance.js') + const { reviewKind, reviewText, addedText, reviewCounts, onBehalf } = require(dir + '/provenance.js') const row = { TenantId: 't', CreatedDateTime: '2026-09-17T08:00:03.000Z', TimeGenerated: '2026-09-17T08:00:05.123Z', OperationName: 'Sign-in', IPAddress: '192.0.2.5', Empty: '' } assert.equal(timeSourceOf(row, '2026-09-17T08:00:05.123Z'), 'TimeGenerated') assert.equal(timeSourceOf(row, '2026-09-17T08:00:03.000Z'), 'CreatedDateTime') @@ -48,7 +48,16 @@ console.log('Explanations: truth reasons match the projection, glossary complete assert.match(reviewText({ ...base, review_kind: 'agent', reviewer_name: 'Claude' }), /not checked by an analyst/) assert.equal(reviewText({ ...base, review_kind: 'analyst', reviewer_name: 'Jo' }), 'Reviewed by Jo') assert.equal(addedText({ created_by: 'Claude', created_via: 'MCP' }), 'Claude via MCP') - assert.equal(addedText({ created_by: 'Import', created_via: 'MCP' }), 'An agent via MCP (import)') + assert.equal(addedText({ created_by: 'Import', created_via: 'MCP' }), 'Import by an agent via MCP') + // The analyst whose board token the agent used. + assert.equal(addedText({ created_by: 'MCP (Gregor)', created_via: 'MCP' }), 'Agent via MCP (Gregor)') + assert.equal(addedText({ created_by: 'Claude (Gregor)', created_via: 'MCP' }), 'Claude via MCP (Gregor)') + assert.equal(addedText({ created_by: 'Import (Gregor)', created_via: 'REST' }), 'Import by an agent via REST (Gregor)') + assert.equal(reviewText({ review_status: 'confirmed', review_kind: 'agent', reviewer_name: 'REST (Gregor)' }), 'Confirmed by an agent via REST (Gregor), not checked by an analyst') + assert.equal(reviewText({ review_status: 'confirmed', review_kind: 'agent', reviewer_name: null }), 'Confirmed by an agent via REST/MCP, not checked by an analyst') + assert.equal(onBehalf('MCP', 'MCP', 'Gregor'), 'MCP (Gregor)') + assert.equal(onBehalf('API', 'REST', 'Gregor'), 'REST (Gregor)') + assert.equal(onBehalf('Claude (Gregor)', 'MCP', 'Gregor'), 'Claude (Gregor)', 'never twice') assert.equal(addedText({ created_by: 'Import', created_via: 'Import' }), 'File import by an analyst') assert.deepEqual(reviewCounts([{ ...base, review_kind: 'import' }, { ...base, review_status: 'unconfirmed' }, { ...base, review_kind: 'analyst' }]), { import: 1, analyst: 1, agent: 0, open: 1 }) console.log('Original rows: event time column, field order; evidence provenance passed') diff --git a/web/tests/notifications.cjs b/web/tests/notifications.cjs index e512286..2326baf 100644 --- a/web/tests/notifications.cjs +++ b/web/tests/notifications.cjs @@ -9,8 +9,12 @@ const act = (type, payload = {}, extra = {}) => ({ id: `a${++n}`, boardId: 'b', ;(async () => { // Who changed it: own canvas is silent; agents, Enricher and colleagues are told apart. assert.equal(classify([act('entity.add')], 'me'), null) - assert.deepEqual(classify([act('entity.add', {}, { channel: 'MCP', author: 'Claude' })], 'me'), { kind: 'agent', author: 'Claude (MCP)' }) + assert.deepEqual(classify([act('entity.add', {}, { channel: 'MCP', author: 'Claude' })], 'me'), { kind: 'agent', author: 'Claude via MCP' }) assert.deepEqual(classify([act('entity.add', {}, { channel: 'REST', author: 'REST' })], 'me'), { kind: 'agent', author: 'Agent via REST' }) + // Agents work with an analyst's board token: whose agent it was is part of the author. + assert.deepEqual(classify([act('entity.add', {}, { channel: 'MCP', author: 'MCP (Gregor)' })], 'me'), { kind: 'agent', author: 'Agent via MCP (Gregor)' }) + assert.deepEqual(classify([act('entity.add', {}, { channel: 'MCP', author: 'Claude (Gregor)' })], 'me'), { kind: 'agent', author: 'Claude via MCP (Gregor)' }) + assert.equal(classify([act('entity.add', {}, { channel: 'REST', author: 'Enricher (Gregor)' })], 'me').kind, 'enricher') assert.equal(classify([act('entity.add', {}, { channel: 'REST', author: 'Enricher' })], 'me').kind, 'enricher') assert.equal(classify([act('entity.add', {}, { channel: 'Import', author: 'GTIEnricher' })], 'me').author, 'Enricher', 'exports from before the rename') assert.equal(classify([act('entity.add', {}, { actor: 'other', author: 'Ben' })], 'me').kind, 'colleague') @@ -20,7 +24,7 @@ const act = (type, payload = {}, extra = {}) => ({ id: `a${++n}`, boardId: 'b', assert.equal(describe(count(batch)), '2 entities, 1 relationship, 2 new evidence items') const first = noticesFor(new Map([['b1', batch]]), 'me', []) assert.equal(first.created.length, 1) - assert.equal(first.created[0].title, 'Claude (MCP) changed the board') + assert.equal(first.created[0].title, 'Claude via MCP changed the board') assert.deepEqual(first.created[0].target, { kind: 'review' }) // A later chunk of the same batch updates the notice instead of adding one. const more = noticesFor(new Map([['b1', [act('fact.add', {}, { channel: 'MCP', author: 'Claude', batch_id: 'b1' })]]]), 'me', first.created)