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
2 changes: 1 addition & 1 deletion .openai/hosting.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"project_id":"appgprj_6aa6447d2f348191983c86ac410f1ab1","d1":"DB","r2":null}
{"project_id":"appgprj_6aa6447d2f348191983c86ac410f1ab1","d1":"DB","r2":null,"capabilities":["mcp"]}
5 changes: 2 additions & 3 deletions apps/sites/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions apps/sites/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createHash } from "node:crypto";
export function sitesUserId(id: string) {
return "siwc_" + createHash("sha256").update(id).digest("hex");
}
25 changes: 22 additions & 3 deletions apps/sites/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -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) => {
Expand All @@ -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,
Expand All @@ -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,
Expand Down
33 changes: 31 additions & 2 deletions apps/sites/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -99,15 +101,40 @@ 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(
auth,
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",
Expand Down Expand Up @@ -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")
Expand Down
65 changes: 57 additions & 8 deletions apps/web/src/Connections.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,14 +14,54 @@ export function Connections() {
const { data, error, refresh } = useData("/api/connections");
const [editing, setEditing] = useState<any>();
const [actionError, setError] = useState<Error>();
const [managed, setManaged] = useState(false);
const managedConnection = data?.find(
(c: any) => c.client_id === SITES_PLUGIN_CLIENT_ID,
);
return (
<>
<PageHead
title="Assistant connections"
description="Choose which projects each assistant can read or update."
/>
{import.meta.env.VITE_COVE_SITES === "true" && (
<section className="panel">
<h2>Cove plugin</h2>
<p>
Install or connect Cove in your assistant, then choose the projects
it can use here. Connecting alone does not grant project access.
</p>
<button
className="secondary"
disabled={!data}
onClick={() => setManaged(true)}
>
{managedConnection?.status === "revoked"
? "Authorize Cove plugin again"
: managedConnection
? "Edit Cove plugin access"
: "Choose Cove plugin projects"}
</button>
</section>
)}
{managed && (
<GrantForm
clientId={SITES_PLUGIN_CLIENT_ID}
managed
initial={
managedConnection?.status === "revoked"
? undefined
: managedConnection
}
onSaved={() => {
setManaged(false);
refresh();
}}
cancel={() => setManaged(false)}
/>
)}
<section className="panel">
<h2>Connect from your assistant</h2>
<h2>Custom assistant connection</h2>
<p>
Add this remote MCP address in your assistant’s connection settings,
then complete the sign-in and project-permission screen.
Expand Down Expand Up @@ -106,6 +147,7 @@ export function Connections() {
<GrantForm
clientId={editing.client_id}
initial={editing}
managed={editing.client_id === SITES_PLUGIN_CLIENT_ID}
onSaved={() => {
setEditing(undefined);
refresh();
Expand Down Expand Up @@ -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<number | undefined>(
initial ? undefined : 30,
);
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
34 changes: 24 additions & 10 deletions packages/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,19 @@ export function mcpHandler(auth: Auth, service: CoveService, origin: string) {
{ resource: `${origin}/mcp`, requiredScopes: ["cove"] },
);
}
export function makeServer(service: Pick<CoveService, 'listProjects' | 'context' | 'search' | 'update' | 'createHandoff' | 'handoff' | 'compare'>, a: Actor) {
export function makeServer(
service: Pick<
CoveService,
| "listProjects"
| "context"
| "search"
| "update"
| "createHandoff"
| "handoff"
| "compare"
>,
actor: Actor | (() => Promise<Actor>),
) {
const server = new McpServer(
{ name: "cove", version: "0.1.0" },
{
Expand All @@ -50,7 +62,7 @@ export function makeServer(service: Pick<CoveService, 'listProjects' | 'context'
description: string,
schema: z.ZodType,
mutates: boolean,
run: (v: any) => Promise<unknown>,
run: (v: any, a: Actor) => Promise<unknown>,
) => {
server.registerTool(
name,
Expand All @@ -66,7 +78,8 @@ export function makeServer(service: Pick<CoveService, 'listProjects' | 'context'
},
async (v: any) => {
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(
Expand Down Expand Up @@ -109,7 +122,7 @@ export function makeServer(service: Pick<CoveService, 'listProjects' | 'context'
"List only projects granted to this connection. Pages contain at most 50 projects.",
pageSchema,
false,
(v) => service.listProjects(a, v.offset, v.limit),
(v, a) => service.listProjects(a, v.offset, v.limit),
);
register(
"cove_get_context",
Expand All @@ -120,35 +133,36 @@ export function makeServer(service: Pick<CoveService, 'listProjects' | 'context'
detail: z.enum(["concise", "full"]).default("concise"),
}),
false,
(v) => 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",
Expand All @@ -159,7 +173,7 @@ export function makeServer(service: Pick<CoveService, 'listProjects' | 'context'
to: z.number().int().positive(),
}),
false,
(v) => service.compare(a, v.projectId, v.from, v.to),
(v, a) => service.compare(a, v.projectId, v.from, v.to),
);
return server;
}
2 changes: 2 additions & 0 deletions packages/shared/sites-connection.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading