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
19 changes: 10 additions & 9 deletions .github/workflows/run.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ name: Demo
# vars.ROBOSYSTEMS_REF — optional; git ref of the robosystems
# repo the episodes run from
#
# This repo is public, so run logs are world-readable. The workflow
# therefore never mints the MCP connector key (--no-connector): the graph
# id it prints is harmless without a key — generate the connector URL
# from the app's Connect page when the demo needs one.
# Connecting is OAuth, so the run needs to hand over nothing secret: the
# summary prints the graph's MCP URL, which a client adds and then signs
# in against as the demo account. This repo is public and run logs are
# world-readable, so the workflow never mints a header key — run
# `demo-up <episode> --connector-key` locally if a viewer needs one.

on:
workflow_dispatch:
Expand Down Expand Up @@ -71,20 +72,20 @@ jobs:
ROBOSYSTEMS_REF: ${{ vars.ROBOSYSTEMS_REF || 'main' }}
EPISODE: ${{ inputs.episode }}
run: |
uv run python -m demo_loop.main up "$EPISODE" --no-connector
uv run python -m demo_loop.main up "$EPISODE"
{
echo "## Demo tenant up"
echo ""
echo "| Episode | Graph |"
echo "|---|---|"
echo "| Episode | Graph | MCP URL |"
echo "|---|---|---|"
uv run python - <<'EOF'
import json, pathlib
state = json.loads(pathlib.Path(".local/demo-state.json").read_text())
for key, rec in state["episodes"].items():
print(f"| {key} | `{rec['graph_id']}` |")
print(f"| {key} | `{rec['graph_id']}` | `{rec['mcp_url']}` |")
EOF
echo ""
echo "Mint the MCP connector from the app's Connect page. Tear down by dispatching this workflow with action=down and the graph id."
echo "Add the MCP URL in Claude (Settings -> Connectors -> Add custom connector) or Claude Code (\`claude mcp add --transport http demo <url>\`) and sign in as the demo account — consent names this graph. Tear down by dispatching this workflow with action=down and the graph id."
} >> "$GITHUB_STEP_SUMMARY"

- name: Demo down
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# RoboSystems Demo Integration

The demo tenant loop, built on the [integration template](https://github.com/RoboFinSystems/robosystems-integration-template): provision a throwaway company on a deployed RoboSystems environment, load a showcase episode from the public repo's `examples/`, hand back an MCP connector URL for Claude, and tear the whole thing down when the demo ends.
The demo tenant loop, built on the [integration template](https://github.com/RoboFinSystems/robosystems-integration-template): provision a throwaway company on a deployed RoboSystems environment, load a showcase episode from the public repo's `examples/`, hand back the graph's MCP URL for Claude to sign into, and tear the whole thing down when the demo ends.

```bash
just demo-up coffee-roaster # provision + load Driftline + connector URL
just demo-up coffee-roaster # provision + load Driftline + MCP URL
just demo-up saas-startup # Cadence Labs
just demo-up roboinvestor # Meridian fund + cross-graph handshake (loads Cadence first)
just demo-status # what the loop currently holds
Expand All @@ -13,6 +13,19 @@ just demo-down all # tear everything down

Configuration is `.env`: `ROBOSYSTEMS_API_KEY` must belong to the **invoice-billed demo account** — that is what lets `POST /v1/graphs` provision with no Stripe round-trip — and `ROBOSYSTEMS_REF` pins which ref of the public robosystems repo the episodes run from (cloned into `.robosystems/`, a tool-owned cache). Loop state lives in `.local/demo-state.json`. Every step is ordinary API traffic; the loop holds no database access of any kind.

### Connecting the demo to Claude

`demo-up` prints the tenant's per-graph MCP URL — `…/v1/graphs/{graph_id}/mcp`. That URL **is** the handoff:

1. Claude → Settings → Connectors → Add custom connector (or `claude mcp add --transport http demo <url>`).
2. Sign in as the demo account. The graph is named by the URL, so consent shows this tenant rather than a picker, and the grant is bound to this one resource.

The URL is not a credential — safe to paste in chat, a run summary, or a slide. The retired `?token=` connector URL *was* a credential; the API stopped honoring it when OAuth landed.

The loop's own `ROBOSYSTEMS_API_KEY` is unrelated to any of this: OAuth covers MCP clients only, while provisioning, loading and teardown are ordinary REST that takes `X-API-Key`. It stays.

For a viewer who **can't** sign in as the demo account, `demo-up <episode> --connector-key` mints a graph-scoped key to send as an `X-API-Key` header (Claude's "Additional request headers" field, or a Cursor/VS Code `mcp.json`). Off by default, printed once, revoked on teardown, and never valid in a URL.

---

The template scaffold this repo was cut from is documented below.
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ update:
run:
uv run python -m integration.main

# Provision + load a demo episode, print its MCP connector URL
# Provision + load a demo episode, print its MCP URL (sign in with OAuth)
demo-up episode="coffee-roaster":
uv run python -m demo_loop.main up {{episode}}

Expand Down
5 changes: 3 additions & 2 deletions src/demo_loop/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

Orchestrates throwaway demo tenants on a deployed RoboSystems environment:
provision a graph on the invoice-billed demo org, load a showcase episode
from the public robosystems repo's examples, mint a graph-scoped MCP
connector key, and tear the whole thing down when the demo is over.
from the public robosystems repo's examples, print the graph's MCP URL for
Claude to sign into over OAuth, and tear the whole thing down when the demo
is over.
"""
63 changes: 34 additions & 29 deletions src/demo_loop/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
import time
from typing import Any

import httpx

from robosystems_client.client import AuthenticatedClient

from .config import DemoConfig
Expand Down Expand Up @@ -100,43 +98,50 @@ def provision_graph(
def mint_connector_key(cfg: DemoConfig, graph_id: str, name: str) -> tuple[str, str]:
"""Mint a graph-scoped (rfsc) key and return (key_id, key).

The key goes in an ``X-API-Key`` header for clients that cannot sign in;
it never rides in a URL (the ``?token=`` connector URL was the bridge to
OAuth and the API no longer honors it).

The SDK's CreateAPIKeyRequest predates graph scoping, so this posts
directly — the documented escape hatch for operations newer than the
SDK regen.
OAuth is the connection path for anyone who can sign in as the demo
account; this key is the alternative for a viewer who cannot — Claude's
"Additional request headers" field, a Cursor/VS Code ``mcp.json``, a
script. It rides in an ``X-API-Key`` header and never in a URL: the
``?token=`` connector URL was the bridge to OAuth and the API no longer
honors it.
"""
response = httpx.post(
f"{cfg.api_url}/v1/user/api-keys",
headers={"X-API-Key": cfg.api_key, "Content-Type": "application/json"},
json={
"name": name,
"description": f"MCP connector key for demo graph {graph_id}",
"graph_id": graph_id,
},
timeout=30,
from robosystems_client.api.user.create_user_api_key import (
sync_detailed as create_user_api_key,
)
if response.status_code >= 400:
raise SystemExit(
f"Connector key mint failed: HTTP {response.status_code}\n{response.text}"
)
payload = response.json()
return payload["api_key"]["id"], payload["key"]
from robosystems_client.models import CreateAPIKeyRequest, CreateAPIKeyResponse

response = create_user_api_key(
client=make_client(cfg),
body=CreateAPIKeyRequest(
name=name,
description=f"MCP header key for demo graph {graph_id}",
graph_id=graph_id,
),
)
parsed = response.parsed
if response.status_code >= 400 or not isinstance(parsed, CreateAPIKeyResponse):
body = response.content.decode() if response.content else "(no body)"
raise SystemExit(f"Connector key mint failed: HTTP {response.status_code}\n{body}")
return parsed.api_key.id, parsed.key


def mcp_url(cfg: DemoConfig, graph_id: str) -> str:
"""The per-graph MCP endpoint — OAuth-capable clients add it and sign in."""
"""The per-graph MCP endpoint — add the URL, sign in, done.

The graph is pinned by the URL, so the OAuth consent screen names this
tenant instead of offering a picker, and the grant is bound to this
exact resource. Not a credential: safe to print, paste, and publish in
a CI summary.
"""
return f"{cfg.api_url}/v1/graphs/{graph_id}/mcp"


def revoke_key(cfg: DemoConfig, key_id: str) -> None:
response = httpx.delete(
f"{cfg.api_url}/v1/user/api-keys/{key_id}",
headers={"X-API-Key": cfg.api_key},
timeout=30,
from robosystems_client.api.user.revoke_user_api_key import (
sync_detailed as revoke_user_api_key,
)

response = revoke_user_api_key(api_key_id=key_id, client=make_client(cfg))
if response.status_code >= 400:
print(f" WARNING: key {key_id} revocation returned HTTP {response.status_code}")

Expand Down
28 changes: 18 additions & 10 deletions src/demo_loop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@
just demo-down all # tear everything down
just demo-status # what the loop currently holds

State (which graphs the loop provisioned, which connector keys it
minted) lives in `.local/demo-state.json` — local, git-ignored, and only
ever describing throwaway tenants.
Connecting is OAuth: the printed URL is the whole handoff, and whoever
adds it signs in as the demo account. `--connector-key` mints a
graph-scoped header key for a viewer who cannot sign in — off by default,
revoked on teardown.

State (which graphs the loop provisioned, which header keys it minted)
lives in `.local/demo-state.json` — local, git-ignored, and only ever
describing throwaway tenants.
"""

from __future__ import annotations
Expand Down Expand Up @@ -94,14 +99,16 @@ def cmd_up(cfg: DemoConfig, episode_key: str, mint_key: bool = False) -> None:
print(f" MCP URL: {record['mcp_url']}")
print(" Add it in Claude (Settings → Connectors → Add custom connector) or")
print(" Claude Code (claude mcp add --transport http demo <url>), sign in as")
print(" the demo account, and ask about the books. The graph is preselected.")
print(" the demo account, and ask about the books. Consent names this graph;")
print(" no picker, no key to paste. The URL is not a credential.")
if record.get("connector_key"):
print(f" API key: {record['connector_key']}")
print(" For clients that can't sign in: send it as the X-API-Key header.")
print(f" Header key: {record['connector_key']}")
print(" For a viewer who can't sign in: send it as X-API-Key (Claude's")
print(' "Additional request headers", or a Cursor/VS Code mcp.json).')
print(" Scoped to this graph; revoked on teardown. Never put it in a URL.")
else:
print(" API key: not minted — pass --connector-key for header-only")
print(" clients (CI logs are public; the default leaves no key behind).")
print(" Header key: not minted — pass --connector-key if the viewer")
print(" can't sign in (CI logs are public; the default leaves no key).")
print(" Tear down with: just demo-down " + episode.key)
print("=" * 72)

Expand Down Expand Up @@ -162,8 +169,9 @@ def main() -> None:
up.add_argument(
"--connector-key",
action="store_true",
help="Also mint a graph-scoped API key for clients that cannot sign in "
"(sent as X-API-Key; off by default so CI logs never carry a credential)",
help="Also mint a graph-scoped API key for a viewer who cannot sign in "
"as the demo account (sent as X-API-Key; off by default, because OAuth "
"needs no key and CI logs are public)",
)
# Retired: not minting is now the default. Accepted so old invocations keep working.
up.add_argument("--no-connector", action="store_true", help=argparse.SUPPRESS)
Expand Down