diff --git a/.openai/hosting.json b/.openai/hosting.json index 2f3a672..c15504c 100644 --- a/.openai/hosting.json +++ b/.openai/hosting.json @@ -1 +1 @@ -{"project_id":"appgprj_6aa6447d2f348191983c86ac410f1ab1","d1":"DB","r2":null} +{"project_id":"appgprj_6aa6447d2f348191983c86ac410f1ab1","d1":"DB","r2":null,"capabilities":["mcp"]} diff --git a/apps/sites/auth.ts b/apps/sites/auth.ts index 31cce4c..afb58e5 100644 --- a/apps/sites/auth.ts +++ b/apps/sites/auth.ts @@ -3,7 +3,7 @@ import { createAuthEndpoint, APIError } from "better-auth/api"; import { setSessionCookie } from "better-auth/cookies"; import { jwt } from "better-auth/plugins"; import { mcp } from "@better-auth/mcp"; -import { createHash } from "node:crypto"; +import { sitesUserId } from "./identity.js"; import type { Environment } from "./types.js"; export function sitesAuth(env: Environment) { const origin = env.APP_URL; @@ -63,8 +63,7 @@ export function sitesAuth(env: Environment) { throw new APIError("UNAUTHORIZED", { message: "Sign in with ChatGPT first.", }); - const userId = - "siwc_" + createHash("sha256").update(oid).digest("hex"); + const userId = sitesUserId(oid); const returnTo = new URL(ctx.request!.url).searchParams.get("returnTo") || "/projects"; diff --git a/apps/sites/identity.ts b/apps/sites/identity.ts new file mode 100644 index 0000000..9e09339 --- /dev/null +++ b/apps/sites/identity.ts @@ -0,0 +1,4 @@ +import { createHash } from "node:crypto"; +export function sitesUserId(id: string) { + return "siwc_" + createHash("sha256").update(id).digest("hex"); +} diff --git a/apps/sites/service.ts b/apps/sites/service.ts index ec4d055..9c22898 100644 --- a/apps/sites/service.ts +++ b/apps/sites/service.ts @@ -17,6 +17,7 @@ import { type Snapshot, } from "../../packages/shared/context.js"; import type { Database, Statement } from "./types.js"; +import { SITES_PLUGIN_CLIENT_ID } from "../../packages/shared/sites-connection.js"; const now = () => new Date().toISOString(); const size = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)).length; const canonical = (v: any): string => @@ -577,9 +578,22 @@ export class SitesService { }); return a; } - async saveConnection(a: Actor, input: unknown) { + async saveManagedConnection(a: Actor, input: unknown) { + const v = connectionInput.omit({ clientId: true }).parse(input); + return this.saveConnection( + a, + { ...v, clientId: SITES_PLUGIN_CLIENT_ID }, + true, + ); + } + async saveConnection(a: Actor, input: unknown, managed = false) { this.human(a); const v = connectionInput.parse(input); + if ((v.clientId === SITES_PLUGIN_CLIENT_ID) !== managed) + fail( + "INVALID_INPUT", + "Use Cove plugin project permissions for this connection.", + ); if (new Set(v.grants.map((g) => g.projectId)).size !== v.grants.length) fail("INVALID_GRANTS", "Each project may appear only once."); return this.mutate(a, async (w) => { @@ -588,12 +602,17 @@ export class SitesService { a.userId, v.clientId, ); - if (existing?.status === "revoked") + if (existing?.status === "revoked" && !managed) fail( "CONNECTION_REVOKED", "Register a fresh OAuth client after revocation.", 409, ); + if (existing?.status === "revoked" && v.expiresInDays === undefined) + fail( + "INVALID_INPUT", + "Choose a new duration to authorize the Cove plugin again.", + ); const count = await this.first( "SELECT count(*) n FROM site_connections WHERE owner_id=?", a.userId, @@ -604,7 +623,7 @@ export class SitesService { const id = existing?.id || randomUUID(); w.statements.push( this.q( - "INSERT INTO site_connections(id,owner_id,client_id,label,status,expires_at,created_at) VALUES(?,?,?,?,'pending',?,?) ON CONFLICT(owner_id,client_id) DO UPDATE SET label=excluded.label,expires_at=excluded.expires_at", + "INSERT INTO site_connections(id,owner_id,client_id,label,status,expires_at,created_at) VALUES(?,?,?,?,'pending',?,?) ON CONFLICT(owner_id,client_id) DO UPDATE SET label=excluded.label,expires_at=excluded.expires_at,status=CASE WHEN site_connections.status='revoked' THEN 'pending' ELSE site_connections.status END", id, a.userId, v.clientId, diff --git a/apps/sites/worker.ts b/apps/sites/worker.ts index f647c69..aac2af4 100644 --- a/apps/sites/worker.ts +++ b/apps/sites/worker.ts @@ -7,6 +7,8 @@ import { pageSchema, formatHandoff } from "../../packages/shared/context.js"; import { SitesService } from "./service.js"; import { sitesAuth } from "./auth.js"; import type { Environment } from "./types.js"; +import { sitesUserId } from "./identity.js"; +import { SITES_PLUGIN_CLIENT_ID } from "../../packages/shared/sites-connection.js"; const json = (v: unknown, status = 200) => Response.json(v, { status }); async function body(request: Request, limit: number) { if (Number(request.headers.get("content-length")) > limit) @@ -99,7 +101,31 @@ export async function route( } return response; } - if (path === "/api/mcp" || path === "/mcp") { + if (path === "/mcp") { + if (method !== "POST") + return new Response(null, { status: 405, headers: { Allow: "POST" } }); + const managedActor = async () => { + // Only Sites dispatch supplies this identity. Cookies or service access + // alone do not authorize an assistant or supply project grants. + const id = request.headers.get("oai-authenticated-user-id"); + if (!id || id.length > 2048) + throw new DomainError("AUTH_REQUIRED", "Connect the Cove plugin to continue.", 401); + return service.connectionActor(sitesUserId(id), SITES_PLUGIN_CLIENT_ID); + }; + // Authentication failures must reach the HTTP boundary. Discovery remains + // schema-only; per-project permission checks still run inside every tool. + const actor = input?.method === "tools/call" ? await managedActor() : managedActor; + const handler = createMcpHandler( + () => makeServer(service as any, actor), + { legacy: "stateless", responseMode: "json", maxSubscriptions: 0 }, + ); + try { + return await handler.fetch(request); + } finally { + await handler.close(); + } + } + if (path === "/api/mcp") { if (method !== "POST") return new Response(null, { status: 405, headers: { Allow: "POST" } }); return requireMcpAuth( @@ -107,7 +133,8 @@ export async function route( async (req, claims) => { if ( typeof claims.sub !== "string" || - typeof claims.client_id !== "string" + typeof claims.client_id !== "string" || + claims.client_id === SITES_PLUGIN_CLIENT_ID ) throw new DomainError( "AUTH_REQUIRED", @@ -169,6 +196,8 @@ export async function route( } if (path === "/api/connections" && method === "GET") return json(await service.listConnections(a)); + if (path === "/api/connections/sites-plugin" && method === "POST") + return json(await service.saveManagedConnection(a, input)); if (path === "/api/connections" && method === "POST") return json(await service.saveConnection(a, input)); if (path === "/api/connection-failure" && method === "POST") diff --git a/apps/web/src/Connections.tsx b/apps/web/src/Connections.tsx index 114ebac..9000c26 100644 --- a/apps/web/src/Connections.tsx +++ b/apps/web/src/Connections.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { useSearchParams } from "react-router-dom"; +import { SITES_PLUGIN_CLIENT_ID } from "../../../packages/shared/sites-connection.js"; import { useData, ErrorBox, @@ -13,14 +14,54 @@ export function Connections() { const { data, error, refresh } = useData("/api/connections"); const [editing, setEditing] = useState(); const [actionError, setError] = useState(); + const [managed, setManaged] = useState(false); + const managedConnection = data?.find( + (c: any) => c.client_id === SITES_PLUGIN_CLIENT_ID, + ); return ( <> + {import.meta.env.VITE_COVE_SITES === "true" && ( +
+

Cove plugin

+

+ Install or connect Cove in your assistant, then choose the projects + it can use here. Connecting alone does not grant project access. +

+ +
+ )} + {managed && ( + { + setManaged(false); + refresh(); + }} + cancel={() => setManaged(false)} + /> + )}
-

Connect from your assistant

+

Custom assistant connection

Add this remote MCP address in your assistant’s connection settings, then complete the sign-in and project-permission screen. @@ -106,6 +147,7 @@ export function Connections() { { setEditing(undefined); refresh(); @@ -184,15 +226,19 @@ function GrantForm({ initial, onSaved, cancel, + managed = false, }: { clientId: string; initial?: any; + managed?: boolean; onSaved: () => void; cancel: () => void; }) { const [offset, setOffset] = useState(0); const { data, error } = useData(`/api/projects?limit=50&offset=${offset}`); - const [label, setLabel] = useState(initial?.label || ""); + const [label, setLabel] = useState( + initial?.label || (managed ? "Cove plugin" : ""), + ); const [days, setDays] = useState( initial ? undefined : 30, ); @@ -320,12 +366,15 @@ function GrantForm({ onClick={async () => { setBusy(true); try { - await post("/api/connections", { - clientId, - label, - grants, - expiresInDays: days, - }); + await post( + managed ? "/api/connections/sites-plugin" : "/api/connections", + { + ...(managed ? {} : { clientId }), + label, + grants, + expiresInDays: days, + }, + ); onSaved(); } catch (e) { setFailure(e as Error); diff --git a/docs/mcp.md b/docs/mcp.md index 03992f0..53d34fb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4,6 +4,16 @@ Hosted Sites endpoint: `https://cove-context.alx21.chatgpt.site/api/mcp`. Open [ Hosted protected resource discovery is available at [Cove's resource metadata](https://cove-context.alx21.chatgpt.site/.well-known/oauth-protected-resource/api/mcp). The original PostgreSQL distribution uses `/.well-known/oauth-protected-resource/mcp` on its configured origin. Authorization server metadata is provided by Better Auth. Always discover metadata rather than guessing token endpoints. PKCE, resource audience, expiry, and project grants are required. No personal access tokens or demo bearer keys are accepted. +## Cove plugin on Sites + +The managed Cove plugin uses `/mcp` with the identity supplied by Sites. Install or connect Cove in your assistant, then open **Assistant connections → Cove plugin → Choose Cove plugin projects** in Cove. Choose only the projects, permissions and duration you intend to share. Connecting the plugin alone grants no project access. The plugin cannot authorize itself or reuse a custom OAuth client's permissions. + +Tool discovery contains schemas only. Every data call checks the signed-in Site identity and the separate Cove plugin grant, including project scope, permission, expiry and revocation. Website session cookies or platform service access alone do not supply this assistant grant. Revoke access in Assistant connections; using **Authorize Cove plugin again** requires an explicit new project selection and duration. Previously copied content remains outside Cove's revocation control. + +Custom assistant clients continue to use `/api/mcp` and the existing Cove OAuth consent flow. Their existing connections and revoked-client rules are unchanged. The managed plugin's identity is Site-scoped; local development servers must remain on loopback and must never trust arbitrary identity headers from the internet. + +These routes are distinct from the historical native-host verification below. A local test or successful plugin installation does not establish a completed live assistant save/continue conversation; record the actual revision and handoff only after that test succeeds. + | Tool | Inputs | Result | | --- | --- | --- | | `cove_list_projects` | offset, limit (1–50) | Granted projects, IDs, versions, next offset | diff --git a/packages/mcp/server.ts b/packages/mcp/server.ts index e434cb2..234d664 100644 --- a/packages/mcp/server.ts +++ b/packages/mcp/server.ts @@ -37,7 +37,19 @@ export function mcpHandler(auth: Auth, service: CoveService, origin: string) { { resource: `${origin}/mcp`, requiredScopes: ["cove"] }, ); } -export function makeServer(service: Pick, a: Actor) { +export function makeServer( + service: Pick< + CoveService, + | "listProjects" + | "context" + | "search" + | "update" + | "createHandoff" + | "handoff" + | "compare" + >, + actor: Actor | (() => Promise), +) { const server = new McpServer( { name: "cove", version: "0.1.0" }, { @@ -50,7 +62,7 @@ export function makeServer(service: Pick Promise, + run: (v: any, a: Actor) => Promise, ) => { server.registerTool( name, @@ -66,7 +78,8 @@ export function makeServer(service: Pick { try { - const result = await run(v); + const a = typeof actor === "function" ? await actor() : actor; + const result = await run(v, a); const text = JSON.stringify(result); if (Buffer.byteLength(text) > 524288) throw new DomainError( @@ -109,7 +122,7 @@ export function makeServer(service: Pick service.listProjects(a, v.offset, v.limit), + (v, a) => service.listProjects(a, v.offset, v.limit), ); register( "cove_get_context", @@ -120,35 +133,36 @@ export function makeServer(service: Pick service.context(a, v.projectId, v.version, v.detail === "concise"), + (v, a) => + service.context(a, v.projectId, v.version, v.detail === "concise"), ); register( "cove_search", "Search current project context within your read grants. Does not fetch sources.", pageSchema.extend({ query: z.string().min(1).max(200) }), false, - (v) => service.search(a, v.query, v.offset, v.limit), + (v, a) => service.search(a, v.query, v.offset, v.limit), ); register( "cove_update_context", "Replace the full structured context using expectedVersion after reading current context. Preserve existing IDs and fields. Stale saves fail. Use the same requestKey for retries; seven-day deduplication.", updateSchema.extend({ projectId: id }), true, - ({ projectId, ...v }) => service.update(a, projectId, v), + ({ projectId, ...v }, a) => service.update(a, projectId, v), ); register( "cove_create_handoff", "Publish an immutable snapshot from the explicitly identified current revision. Rejects publication if the project changed. Corrections require a new handoff.", handoffInput.extend({ projectId: id }), true, - ({ projectId, ...v }) => service.createHandoff(a, projectId, v), + ({ projectId, ...v }, a) => service.createHandoff(a, projectId, v), ); register( "cove_get_handoff", "Retrieve the original private snapshot and separately identify newer context. Includes readable changes and copy formats. Retrieval is observed, not proof of continuation.", z.object({ projectId: id, handoffId: id }), false, - (v) => service.handoff(a, v.projectId, v.handoffId), + (v, a) => service.handoff(a, v.projectId, v.handoffId), ); register( "cove_get_changes", @@ -159,7 +173,7 @@ export function makeServer(service: Pick service.compare(a, v.projectId, v.from, v.to), + (v, a) => service.compare(a, v.projectId, v.from, v.to), ); return server; } diff --git a/packages/shared/sites-connection.ts b/packages/shared/sites-connection.ts new file mode 100644 index 0000000..2ef9c48 --- /dev/null +++ b/packages/shared/sites-connection.ts @@ -0,0 +1,2 @@ +// Reserved for the Sites-managed Cove plugin, separate from custom OAuth clients. +export const SITES_PLUGIN_CLIENT_ID = "cove:sites-managed-plugin"; diff --git a/tests/sites.test.ts b/tests/sites.test.ts index a3140c7..d7b0972 100644 --- a/tests/sites.test.ts +++ b/tests/sites.test.ts @@ -372,3 +372,226 @@ it("completes OAuth PKCE with two SDK clients and enforces live revocation", asy await two.client.close(); } }); + +async function managedClient(headers: Record = {}) { + const { Client, StreamableHTTPClientTransport } = + await import("@modelcontextprotocol/client"); + const client = new Client({ name: "Managed fixture", version: "1.0.0" }); + await client.connect( + new StreamableHTTPClientTransport(new URL(origin + "/mcp"), { + fetch: async (url, init) => worker.fetch(new Request(url, init), env), + requestInit: { headers }, + }), + ); + return client; +} + +it("discovers managed schemas without data access and does not inherit browser or custom-client grants", async () => { + const p = await service.createProject(actor, { name: "Managed fixture" }); + await service.saveConnection(actor, { + clientId: "existing-custom-client", + label: "Existing client", + grants: [{ projectId: p.id, capabilities: ["read", "write", "handoff"] }], + }); + const anonymous = await managedClient(); + const browserOnly = await managedClient({ + cookie, + "oai-authenticated-user-email": "alice@example.test", + }); + const ungranted = await managedClient({ + "oai-authenticated-user-id": "fixture-alice", + }); + try { + expect((await anonymous.listTools()).tools).toHaveLength(7); + for (const client of [anonymous, browserOnly, ungranted]) { + await expect( + client.callTool({ name: "cove_list_projects", arguments: {} }), + ).rejects.toThrow(); + } + const rpc = { + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "cove_list_projects", arguments: {} }, + }; + const unauthenticated = await call("/mcp", "POST", rpc); + expect(unauthenticated.response.status).toBe(401); + expect(unauthenticated.data.error.code).toBe("AUTH_REQUIRED"); + const unapproved = await call("/mcp", "POST", rpc, { + "oai-authenticated-user-id": "fixture-alice", + }); + expect(unapproved.response.status).toBe(403); + expect(unapproved.data.error.code).toBe("ACCESS_DENIED"); + const { SITES_PLUGIN_CLIENT_ID } = + await import("../packages/shared/sites-connection.js"); + const reserved = await call("/api/connections", "POST", { + clientId: SITES_PLUGIN_CLIENT_ID, + label: "Attempt", + grants: [], + }); + expect(reserved.response.status).toBe(400); + const noSession = await call( + "/api/connections/sites-plugin", + "POST", + { label: "Attempt", grants: [] }, + { cookie: "" }, + ); + expect(noSession.response.status).toBe(401); + const crossOrigin = await call( + "/api/connections/sites-plugin", + "POST", + { label: "Attempt", grants: [] }, + { origin: "https://other.example" }, + ); + expect(crossOrigin.response.status).toBe(403); + expect(await service.listConnections(actor)).toHaveLength(1); + } finally { + await Promise.all([ + anonymous.close(), + browserOnly.close(), + ungranted.close(), + ]); + } +}); + +it("enforces managed project permissions, identity isolation, save/handoff, expiry and explicit reauthorization", async () => { + const { SITES_PLUGIN_CLIENT_ID } = + await import("../packages/shared/sites-connection.js"); + const granted = await service.createProject(actor, { + name: "Allowed fixture", + }); + const privateProject = await service.createProject(actor, { + name: "Unshared fixture", + }); + const input = { + label: "Cove plugin", + grants: [{ projectId: granted.id, capabilities: ["read"] }], + }; + const saved = await call("/api/connections/sites-plugin", "POST", { + ...input, + expiresInDays: 7, + }); + expect(saved.response.status).toBe(200); + const connection = (await service.listConnections(actor)).find( + (c) => c.id === saved.data.id, + )!; + expect(connection.client_id).toBe(SITES_PLUGIN_CLIENT_ID); + const client = await managedClient({ + "oai-authenticated-user-id": "fixture-alice", + }); + const other = await managedClient({ + "oai-authenticated-user-id": "fixture-bob", + }); + const list = () => + client.callTool({ name: "cove_list_projects", arguments: {} }); + const update = { + projectId: granted.id, + expectedVersion: 1, + context: { ...emptyContext(), goal: "The fictional blanket is blue." }, + summary: "Fixture save", + requestKey: randomUUID(), + }; + try { + const projects = await list(); + expect( + (projects.structuredContent as any).items.map((p: any) => p.id), + ).toEqual([granted.id]); + await expect( + other.callTool({ + name: "cove_get_context", + arguments: { projectId: granted.id, detail: "full" }, + }), + ).rejects.toThrow(); + expect( + ( + await client.callTool({ + name: "cove_get_context", + arguments: { projectId: privateProject.id, detail: "full" }, + }) + ).isError, + ).toBe(true); + expect( + ( + await client.callTool({ + name: "cove_update_context", + arguments: update, + }) + ).isError, + ).toBe(true); + expect((await service.context(actor, granted.id)).project.version).toBe(1); + const upgraded = await call("/api/connections/sites-plugin", "POST", { + ...input, + grants: [ + { projectId: granted.id, capabilities: ["read", "write", "handoff"] }, + ], + }); + expect(upgraded.response.status).toBe(200); + expect( + (await service.listConnections(actor)).find( + (c) => c.id === saved.data.id, + )!.expires_at, + ).toBe(connection.expires_at); + const changed = await client.callTool({ + name: "cove_update_context", + arguments: update, + }); + expect(changed.isError, JSON.stringify(changed)).not.toBe(true); + expect((await service.context(actor, granted.id)).project.version).toBe(2); + const handoff = await client.callTool({ + name: "cove_create_handoff", + arguments: { + projectId: granted.id, + expectedVersion: 2, + requestKey: randomUUID(), + }, + }); + expect(handoff.isError, JSON.stringify(handoff)).not.toBe(true); + const snapshot = await client.callTool({ + name: "cove_get_handoff", + arguments: { + projectId: granted.id, + handoffId: (handoff.structuredContent as any).id, + }, + }); + expect(snapshot.isError, JSON.stringify(snapshot)).not.toBe(true); + expect(JSON.stringify(snapshot.structuredContent)).toContain( + "The fictional blanket is blue.", + ); + await service.revoke(actor, saved.data.id); + await expect(list()).rejects.toThrow(); + const noDuration = await call( + "/api/connections/sites-plugin", + "POST", + input, + ); + expect(noDuration.response.status).toBe(400); + await expect(list()).rejects.toThrow(); + const reauthorized = await call("/api/connections/sites-plugin", "POST", { + ...input, + expiresInDays: 7, + }); + expect(reauthorized.response.status).toBe(200); + expect((await list()).isError).not.toBe(true); + expect( + ( + await client.callTool({ + name: "cove_update_context", + arguments: { + ...update, + expectedVersion: 2, + requestKey: randomUUID(), + }, + }) + ).isError, + ).toBe(true); + db.sql + .prepare("UPDATE site_connections SET expires_at=? WHERE id=?") + .run("2020-01-01T00:00:00.000Z", saved.data.id); + await expect(list()).rejects.toThrow(); + expect( + (await service.context(actor, privateProject.id)).project.version, + ).toBe(1); + } finally { + await Promise.all([client.close(), other.close()]); + } +});