Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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=` |
Expand All @@ -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" \
Expand All @@ -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)
```

Expand Down
2 changes: 1 addition & 1 deletion app/descriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"
Expand Down
40 changes: 31 additions & 9 deletions app/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -237,34 +238,51 @@ 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}')
core['MCP_TOOL_PROFILE'] = set_tool_profile(mcp, configured_profile(), agent_names, full_names)
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:
Expand All @@ -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__
20 changes: 17 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion tests/test_mcp_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)
Loading
Loading