diff --git a/README.md b/README.md index 82c46a8e9..e3eb68793 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ Leave `EMBEDDED_POSTGRES` off and set `DATABASE_URL` to point at a database you - **Secrets never enter the transcript**: the trail records that a secret was requested and how long it was, not what it said. - **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand-written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only. - **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component. -- **Governed MCP**: Google Drive and Notion ship in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website. +- **Governed MCP**: Context.dev, Google Drive and Notion ship in the catalogue, reached as the person asking. The catalogue carries only vendors this deployment stands behind, so adding one is a review of that vendor. Custom servers must pass URL checks; unknown tools and custom-server tools are treated as writes, and a catalogue tool the server advertises but does not name as a write classifies as a read. A Bot is told which connectors exist here and which it holds, so it says it has not been granted one rather than browsing to the vendor's website. - **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer. A Bot granted the shipped `skill-creator` skill can write one with you in the conversation, and saves it only when you press the button on the card. - **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once. - **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail. diff --git a/docs/README.md b/docs/README.md index 6b7c66687..cc6036118 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Start with the root [README](../README.md), then use these references: - [Coworkers](coworkers.md): durable Bot profiles, channels, visibility, deletion, and external AG-UI registration. - [Routines](routines.md): standing instructions a Bot runs on a schedule, the worker that fires them, and who they run as. - Plugins, one connector per page — what an administrator registers, what each person consents to, and what the failures mean: + - [Context.dev](plugins/context-dev.md) - [Google Drive](plugins/google-drive.md) - [Notion](plugins/notion.md) - [Deployment](deployment.md): the container, what is in the image, minimum sizes, and the platform notes. diff --git a/docs/plugins/context-dev.md b/docs/plugins/context-dev.md new file mode 100644 index 000000000..a7bc1112c --- /dev/null +++ b/docs/plugins/context-dev.md @@ -0,0 +1,61 @@ +# Context.dev + +A Bot with this connector granted reaches Context.dev **as the person asking**, through the official +hosted MCP server at `mcp.context.dev`. It can search the live web and company news, scrape and crawl +sites, extract structured data, parse files, retrieve brand intelligence, capture screenshots, run +large batches and monitor websites for changes. + +Setting it up takes two people, and neither can do the other's half: + +| Who | Does | Where | +| ---------------- | -------------------------------------- | ------------------------------ | +| An administrator | Enables the connector and its tools | `/admin/plugins/context-dev` | +| Each person | Connects their own Context.dev account | `/settings/connected-accounts` | + +There is no deployment-wide Context.dev API key. Each person authorizes their own account in the +browser, and calls use that account's access and credits. + +## What an administrator does + +### 1. Enable the connector + +At `/admin/plugins/context-dev`, turn on **Enable for this deployment**. There is no OAuth client ID +or secret to paste: OpenBot registers itself with Context.dev on the first connection using dynamic +client registration and PKCE. + +The deployment needs a public callback address derived from `OPENBOT_PUBLIC_URL`, or from the auth +base URL when that is not set. A local installation can use its loopback address. + +### 2. Connect your own account and refresh tools + +Use **Your account** on the same page to connect your Context.dev account, then press **Refresh +tools**. Context.dev's tool catalogue is discovered from its hosted server using the account of the +person who pressed the button; OpenBot does not borrow another person's grant. + +### 3. Grant tools to a Bot + +Enabling Context.dev gives no Bot access by itself. Grant only the tools that Bot needs. Every call +then passes through OpenBot's action policy and audit trail. + +## What each person does + +Open Context.dev under `/settings/connected-accounts` and press **Connect**. Sign in on Context.dev's +consent screen and approve the requested access. OpenBot stores the refresh token encrypted and +uses short-lived access tokens for calls. + +Context.dev requests two scopes: + +- `api.read` for read-only search, news, crawl, extraction, brand, screenshot and account-history + tools; +- `api.write` for file parsing, browser-action-capable scrape tools, monitors and batch jobs. + +The write classification is deliberately conservative. `web-scrape-html`, +`web-scrape-markdown` and `web-scrape-images` can run browser actions on third-party pages. Parsing, +monitor creation and batch submission can consume credits or create persistent work. These tools are +therefore governed as writes alongside updates, runs, cancellations and deletions. + +## See also + +- [Context.dev MCP documentation](https://docs.context.dev/install-mcp) +- [Architecture](../architecture.md) — where plugins, grants, policy and audit sit. +- [Configuration](../configuration.md) — `OPENBOT_PUBLIC_URL`, `OPENBOT_APP_URL`, `KEY_ENCRYPTION_KEY`. diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index b8445bfce..7d3c1e9cb 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -151,6 +151,44 @@ export type CatalogueEntry = { * the other end of it. */ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ + { + key: "context-dev", + title: "Context.dev", + vendor: "Context.dev", + summary: + "Live web, company news, files and brand data for whoever is asking.", + host: "https://mcp.context.dev", + path: "/mcp", + auth: { + kind: "user-oauth", + authorizationUrl: "https://mcp.context.dev/authorize", + tokenUrl: "https://mcp.context.dev/token", + revokeUrl: "https://www.context.dev/oauth2/revoke", + scopes: Object.freeze(["api.read", "api.write"]), + clientRegistration: "dynamic", + registrationUrl: "https://mcp.context.dev/register", + }, + /* + * Every tool Context advertises with `readOnlyHint: false` in its production catalogue. The + * scrape tools are here because their optional browser actions can change third-party pages; + * parsing, monitors and batches consume credits or create persistent work even when they do not + * delete data. Keeping all of them on the governed side is safer than inferring from the verb. + */ + writeTools: Object.freeze([ + "parse-document", + "web-scrape-html", + "web-scrape-markdown", + "web-scrape-images", + "create-monitor", + "update-monitor", + "delete-monitor", + "run-monitor-now", + "submit-batch", + "cancel-batch", + "delete-batch", + ]), + docsUrl: "https://docs.context.dev/install-mcp", + }, { key: "google-drive", title: "Google Drive", diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index d6b533a4a..3d16f0873 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -192,6 +192,59 @@ describe("Google Drive", () => { }); }); +describe("Context.dev", () => { + const entry = catalogueEntry("context-dev"); + + test("pins the hosted MCP server and refuses lookalike hosts", () => { + expect(entry).not.toBeNull(); + expect(entry?.transport).toBeUndefined(); + expect(resolveServerUrl("context-dev")?.url).toBe( + "https://mcp.context.dev/mcp", + ); + expect(hostAdmissible(entry!, "https://mcp.context.dev")).toBe(true); + expect(hostAdmissible(entry!, "https://mcp.context.dev.evil.test")).toBe( + false, + ); + expect(hostAdmissible(entry!, "http://mcp.context.dev")).toBe(false); + }); + + test("uses Context OAuth with dynamic client registration", () => { + if (entry?.auth.kind !== "user-oauth") throw new Error("wrong auth kind"); + expect(entry.auth).toEqual({ + kind: "user-oauth", + authorizationUrl: "https://mcp.context.dev/authorize", + tokenUrl: "https://mcp.context.dev/token", + revokeUrl: "https://www.context.dev/oauth2/revoke", + scopes: ["api.read", "api.write"], + clientRegistration: "dynamic", + registrationUrl: "https://mcp.context.dev/register", + }); + }); + + test("pins every production tool that is not read-only", () => { + expect(entry?.writeTools).toEqual([ + "parse-document", + "web-scrape-html", + "web-scrape-markdown", + "web-scrape-images", + "create-monitor", + "update-monitor", + "delete-monitor", + "run-monitor-now", + "submit-batch", + "cancel-batch", + "delete-batch", + ]); + for (const name of entry?.writeTools ?? []) { + expect(classifyTool(entry, name, true)).toBe("write"); + } + expect(classifyTool(entry, "get-news-search", true)).toBe("read"); + expect(classifyTool(entry, "web-search", true)).toBe("read"); + expect(classifyTool(entry, "get-brand", true)).toBe("read"); + expect(classifyTool(entry, "brand-new-tool", false)).toBe("write"); + }); +}); + describe("Notion", () => { const entry = catalogueEntry("notion");