Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
71644dd
feat(plugins): connect a hosted MCP server with no OAuth app to register
harshithmullapudi Sep 8, 2026
098ca85
feat(plugins): add Slack and Gmail, move Linear onto a dynamic client
harshithmullapudi Sep 8, 2026
8ef480a
chore(db): renumber the plugin_oauth_clients migration after rebase
harshithmullapudi Sep 8, 2026
79f58c5
fix(plugins): stop the OAuth module from evaluating the database client
harshithmullapudi Sep 8, 2026
9bf5469
fix(plugins): correct four faults the review found in the OAuth path
harshithmullapudi Sep 8, 2026
73e2148
feat(plugins): add the Google plugins to the desktop install catalog
harshithmullapudi Sep 8, 2026
8eb02b8
feat(plugins): give Slack and Gmail their own MCP servers
harshithmullapudi Sep 8, 2026
91e19e0
fix(cli): make a connection id the only way to name an mcp target
harshithmullapudi Sep 8, 2026
73b035e
fix(plugins): correct nine faults the review found in the OAuth path
harshithmullapudi Sep 8, 2026
c64597c
fix(plugins): converge concurrent dynamic client registrations
harshithmullapudi Sep 8, 2026
2d481c7
fix(plugins): address the PR review on the OAuth and Gmail paths
harshithmullapudi Sep 9, 2026
d5cb17a
test(plugins): drop the dynamic client suite for ORM-free discovery t…
harshithmullapudi Sep 9, 2026
72cd566
fix(plugins): correct the faults cubic found in the review commit
harshithmullapudi Sep 9, 2026
a236abe
fix(plugins): render the RFC Message-ID and fail an unrefreshable con…
harshithmullapudi Sep 9, 2026
a528541
docs(plugins): say what PLUGIN_CLIENT_METADATA_BASE_URL cannot do
harshithmullapudi Sep 9, 2026
64a8276
Merge remote-tracking branch 'origin/main' into integration-plugins
harshithmullapudi Sep 9, 2026
3278e2a
feat(connectors): move integrations onto the connections table
harshithmullapudi Sep 14, 2026
315cf7e
Merge remote-tracking branch 'origin/main' into integration-plugins
harshithmullapudi Sep 14, 2026
4184952
fix(db): drop the automation_events FK that would abort migration 0115
harshithmullapudi Sep 14, 2026
0cff967
fix(connectors): resolve user-scoped connectors per member
harshithmullapudi Sep 14, 2026
21b0a4e
fix(plugins): make the dynamic OAuth client path usable
harshithmullapudi Sep 14, 2026
326fc33
refactor(cli): fold plugins connections into plugins list
harshithmullapudi Sep 14, 2026
eec2ba7
style(plugins): format the manifests biome rejects
harshithmullapudi Sep 14, 2026
1537119
fix(connectors): green CI, RFC 9207 iss check, dedicated secrets key
harshithmullapudi Sep 14, 2026
c571b3b
refactor(plugins): manifests name connectors, and the schema we serve…
harshithmullapudi Sep 14, 2026
43b8153
style(plugins): silence the placeholder-syntax lint in the manifest s…
harshithmullapudi Sep 14, 2026
0c8b7d1
refactor(plugins): serve plugin tools from an MCP endpoint, and close…
harshithmullapudi Sep 15, 2026
47059ff
Merge remote-tracking branch 'origin/main' into integration-plugins
harshithmullapudi Sep 15, 2026
5045973
Merge remote-tracking branch 'origin/main' into integration-plugins
harshithmullapudi Sep 17, 2026
ccea4a5
style(slack): format the plugin-server test stub biome rejects
harshithmullapudi Sep 17, 2026
3799409
fix(connectors): close the review's remaining checklist
harshithmullapudi Sep 17, 2026
b03b7ac
test(connectors): cover the ambiguity, needs-reauth and bad-gateway r…
harshithmullapudi Sep 17, 2026
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
34 changes: 32 additions & 2 deletions .agent-marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
},
"category": "development",
"source": "./plugins/github",
"version": "1.0.0"
"version": "1.0.1"
},
{
"name": "linear",
Expand All @@ -25,7 +25,37 @@
},
"category": "productivity",
"source": "./plugins/linear",
"version": "1.4.0"
"version": "1.5.2"
},
{
"name": "notion",
"description": "Search, read, and write your Notion workspace: pages, databases, and comments.",
"author": {
"name": "Superset"
},
"category": "productivity",
"source": "./plugins/notion",
"version": "1.0.2"
},
{
"name": "slack",
"description": "Search, read, and post across your Slack workspace: channels, DMs, and threads.",
"author": {
"name": "Superset"
},
"category": "communication",
"source": "./plugins/slack",
"version": "1.2.3"
},
{
"name": "gmail",
"description": "Read, search, send, and organize mail in your Gmail account.",
"author": {
"name": "Superset"
},
"category": "communication",
"source": "./plugins/gmail",
"version": "1.1.5"
}
],
"featured": ["github", "linear"],
Expand Down
21 changes: 21 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,27 @@ POSTHOG_PROJECT_ID=
GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT=
GOOGLE_SEARCH_CONSOLE_SITE_URL=sc-domain:superset.sh

# -----------------------------------------------------------------------------
# Plugin OAuth (optional — falls back to NEXT_PUBLIC_API_URL)
Comment thread
harshithmullapudi marked this conversation as resolved.
Resolved
# Origin an authorization server fetches a plugin's client id metadata document
# from. Setting this alone does not make local dev work: redirect_uris in that
# document come from NEXT_PUBLIC_API_URL, and the document declares
# application_type "web", so a loopback redirect is rejected. To connect a
# dynamic-client plugin locally, point one public https tunnel at the API and
# set NEXT_PUBLIC_API_URL to it; this variable is for the case where the two
# origins genuinely differ.
# -----------------------------------------------------------------------------
PLUGIN_CLIENT_METADATA_BASE_URL=

# -----------------------------------------------------------------------------
# Connector OAuth clients
# Only for connectors whose requires_env names a pair that no other feature
# already sets — the rest reuse the integration clients above. Without them the
# connector still lists, but Connect answers "not configured".
# -----------------------------------------------------------------------------
PLUGIN_GITHUB_CLIENT_ID=
PLUGIN_GITHUB_CLIENT_SECRET=

# -----------------------------------------------------------------------------
# Sentry Error Tracking
# -----------------------------------------------------------------------------
Expand Down
21 changes: 21 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,27 @@ POSTHOG_PROJECT_ID=00000
GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT=
GOOGLE_SEARCH_CONSOLE_SITE_URL=sc-domain:superset.sh

# -----------------------------------------------------------------------------
# Plugin OAuth (optional — falls back to NEXT_PUBLIC_API_URL)
# Origin an authorization server fetches a plugin's client id metadata document
# from. Setting this alone does not make local dev work: redirect_uris in that
# document come from NEXT_PUBLIC_API_URL, and the document declares
# application_type "web", so a loopback redirect is rejected. To connect a
# dynamic-client plugin locally, point one public https tunnel at the API and
# set NEXT_PUBLIC_API_URL to it; this variable is for the case where the two
# origins genuinely differ.
# -----------------------------------------------------------------------------
PLUGIN_CLIENT_METADATA_BASE_URL=

# -----------------------------------------------------------------------------
# Connector OAuth clients
# Only for connectors whose requires_env names a pair that no other feature
# already sets — the rest reuse the integration clients above. Without them the
# connector still lists, but Connect answers "not configured".
# -----------------------------------------------------------------------------
PLUGIN_GITHUB_CLIENT_ID=
PLUGIN_GITHUB_CLIENT_SECRET=

# -----------------------------------------------------------------------------
# Sentry Error Tracking (optional — leave blank to disable)
# -----------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ jobs:
NEXT_PUBLIC_POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT }}
PLUGIN_CLIENT_METADATA_BASE_URL: ${{ secrets.PLUGIN_CLIENT_METADATA_BASE_URL }}
PLUGIN_GITHUB_CLIENT_ID: ${{ secrets.PLUGIN_GITHUB_CLIENT_ID }}
PLUGIN_GITHUB_CLIENT_SECRET: ${{ secrets.PLUGIN_GITHUB_CLIENT_SECRET }}
POSTHOG_PROJECT_ID: ${{ secrets.POSTHOG_PROJECT_ID }}
KV_REST_API_URL: ${{ secrets.KV_REST_API_URL }}
KV_REST_API_TOKEN: ${{ secrets.KV_REST_API_TOKEN }}
Expand Down Expand Up @@ -225,6 +228,9 @@ jobs:
--env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \
--env POSTHOG_API_KEY=$POSTHOG_API_KEY \
--env GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT="$GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT" \
--env PLUGIN_CLIENT_METADATA_BASE_URL="$PLUGIN_CLIENT_METADATA_BASE_URL" \
--env PLUGIN_GITHUB_CLIENT_ID="$PLUGIN_GITHUB_CLIENT_ID" \
--env PLUGIN_GITHUB_CLIENT_SECRET="$PLUGIN_GITHUB_CLIENT_SECRET" \
--env POSTHOG_PROJECT_ID=$POSTHOG_PROJECT_ID \
--env KV_REST_API_URL=$KV_REST_API_URL \
--env KV_REST_API_TOKEN=$KV_REST_API_TOKEN \
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ jobs:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
MERCURY_API_TOKEN: ${{ secrets.MERCURY_API_TOKEN }}
GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT: ${{ secrets.GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT }}
PLUGIN_CLIENT_METADATA_BASE_URL: ${{ secrets.PLUGIN_CLIENT_METADATA_BASE_URL }}
PLUGIN_GITHUB_CLIENT_ID: ${{ secrets.PLUGIN_GITHUB_CLIENT_ID }}
PLUGIN_GITHUB_CLIENT_SECRET: ${{ secrets.PLUGIN_GITHUB_CLIENT_SECRET }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }}
STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }}
Expand Down Expand Up @@ -224,6 +227,9 @@ jobs:
--env STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY \
--env MERCURY_API_TOKEN=$MERCURY_API_TOKEN \
--env GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT="$GOOGLE_SEARCH_CONSOLE_SERVICE_ACCOUNT" \
--env PLUGIN_CLIENT_METADATA_BASE_URL="$PLUGIN_CLIENT_METADATA_BASE_URL" \
--env PLUGIN_GITHUB_CLIENT_ID="$PLUGIN_GITHUB_CLIENT_ID" \
--env PLUGIN_GITHUB_CLIENT_SECRET="$PLUGIN_GITHUB_CLIENT_SECRET" \
--env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \
--env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \
--env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \
Expand Down
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,11 @@ Desktop, host-service, and cli share one version; cut releases on a dedicated br

## Plugins

First-party plugins live in `plugins/<name>/`: a `plugin.json` manifest, `skills/`, and optionally
an MCP server. A release is the git tag `<name>@<version>` on this repo — that tree is what a host
First-party plugins live in `plugins/<name>/`: a `plugin.json` manifest and `skills/`. A plugin
ships no code — agents reach its tools through `/mcp/plugins/<marketplace>/<plugin>` on the API,
which proxies to the vendor's MCP server or, for the plugins named in the `SUPERSET_HOSTED_PLUGINS` constant,
serves them from `packages/trpc/src/router/plugins/servers/`. A release is the git tag
`<name>@<version>` on this repo — that tree is what a host
downloads — and `packages/shared/src/plugins/manifests.generated.ts` is the bundle the API resolves
against, which is generated and must never be hand-edited. Change the source, then
`superset plugins publish <name> --bump patch`, which rewrites the marketplace entry in
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getConnector } from "@superset/shared/connectors";
import { clientMetadataUrl, redirectUriFor } from "@superset/trpc/connectors";

export async function GET(
_request: Request,
{ params }: { params: Promise<{ connector: string }> },
) {
const { connector: slug } = await params;
if (!getConnector(slug)) {
return Response.json({ error: "Unknown connector" }, { status: 404 });
}

return Response.json(
{
client_id: clientMetadataUrl(slug),
client_name: "Superset",
client_uri: "https://superset.sh",
redirect_uris: [redirectUriFor(slug)],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
application_type: "web",
},
{
headers: {
"cache-control": "public, max-age=3600",
"content-type": "application/json",
},
},
);
}
91 changes: 91 additions & 0 deletions apps/api/src/app/api/connectors/[connector]/connect/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ConnectorMethod } from "@superset/shared/connectors";
import {
authorizeUrl,
connectorMethod,
createCodeVerifier,
MissingConnectorEnvError,
redirectUriFor,
requireConnector,
resolveEndpoints,
UnknownConnectorError,
} from "@superset/trpc/connectors";
import { encryptSecret } from "@superset/trpc/integrations/plugins";

import { env } from "@/env";
import { requireOrgMember } from "@/lib/integrations/requireOrgMember";
import { createSignedState } from "@/lib/oauth-state";

export const STATE_COOKIE = "connector_oauth_state";

export async function GET(
request: Request,
{ params }: { params: Promise<{ connector: string }> },
) {
const { connector: slug } = await params;
const member = await requireOrgMember(request);
if (member instanceof Response) return member;

// Same reason as the callback: the generic connect page covers every
// connector, `/integrations/<slug>` only the seven older ones.
const connectUrl = `${env.NEXT_PUBLIC_WEB_URL}/connect/${slug}`;
const requested = new URL(request.url).searchParams.get("method");

let method: ConnectorMethod;
try {
const connector = requireConnector(slug);
method = connectorMethod(
connector,
(requested as ConnectorMethod["type"] | null) ?? undefined,
);
} catch (error) {
if (error instanceof UnknownConnectorError)
return Response.json({ error: "Unknown connector" }, { status: 404 });
throw error;
}

if (method.type === "api_key")
return Response.json(
{ error: "This method takes inputs, not a redirect" },
{ status: 400 },
);

let target: string;
let state: string;
try {
const redirect = redirectUriFor(slug);
const wantsPkce =
method.type === "oauth2" &&
(await resolveEndpoints(slug, method, redirect)).pkce;
const codeVerifier = wantsPkce ? createCodeVerifier() : null;
state = codeVerifier
? createSignedState({
organizationId: member.organizationId,
userId: member.userId,
codeVerifier: await encryptSecret(codeVerifier),
})
: member.state;

target = (
await authorizeUrl(slug, method, {
redirectUri: redirect,
state,
codeVerifier,
})
).url;
} catch (error) {
if (error instanceof MissingConnectorEnvError) {
console.error(`[connectors/${slug}] ${error.message}`);
return Response.redirect(`${connectUrl}?error=not_configured`);
}
throw error;
}

const secure = env.NEXT_PUBLIC_API_URL.startsWith("https") ? " Secure;" : "";
return new Response(null, {
status: 302,
headers: {
Location: target,
"Set-Cookie": `${STATE_COOKIE}=${state}; HttpOnly;${secure} SameSite=Lax; Path=/api/connectors; Max-Age=600`,
},
});
}
117 changes: 117 additions & 0 deletions apps/api/src/app/api/connectors/callback/[connector]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { ConnectorMethod } from "@superset/shared/connectors";
import {
connectorMethod,
exchangeCode,
probeIdentity,
redirectUriFor,
requireConnector,
UnknownConnectorError,
upsertConnection,
} from "@superset/trpc/connectors";
import { decryptSecret } from "@superset/trpc/integrations/plugins";
import { STATE_COOKIE } from "@/app/api/connectors/[connector]/connect/route";
import { env } from "@/env";
import { resolveCallback } from "@/lib/integrations/resolveCallback";
import { connectorStateSchema, verifySignedState } from "@/lib/oauth-state";

function callbackParams(method: ConnectorMethod): string[] {
if (method.type === "oauth2") return ["code"];
if (method.type === "app_install" || method.type === "admin_consent")
return [...method.callback_params];
return [];
}

function stateCookie(request: Request): string | null {
const cookie = request.headers.get("cookie") ?? "";
const match = cookie.match(new RegExp(`(?:^|;\\s*)${STATE_COOKIE}=([^;]+)`));
return match?.[1] ?? null;
}

export async function GET(
request: Request,
{ params }: { params: Promise<{ connector: string }> },
) {
const { connector: slug } = await params;
// The generic connect page exists for every connector; `/integrations/<slug>`
// only exists for the seven that predate the registry, so a new connector
// would 404 the moment someone finished authorizing it.
const connectUrl = `${env.NEXT_PUBLIC_WEB_URL}/connect/${slug}`;
const web = (query: string) => Response.redirect(`${connectUrl}${query}`);

let connector: ReturnType<typeof requireConnector>;
let method: ConnectorMethod;
try {
connector = requireConnector(slug);
method = connectorMethod(connector);
} catch (error) {
if (error instanceof UnknownConnectorError)
return Response.json({ error: "Unknown connector" }, { status: 404 });
throw error;
}

const callback = await resolveCallback(request, {
params: callbackParams(method),
redirect: (error) => web(`?error=${error}`),
stateFrom: method.type === "app_install" ? stateCookie : undefined,
});
if (callback instanceof Response) return callback;

// The signed state proves who asked, not who is answering. Without this the
// authorization code a victim approves can be paired with a state minted by
// an attacker, binding the victim's account to the attacker's connection.
if (method.type !== "app_install") {
const query = new URL(request.url).searchParams.get("state");
if (!query || query !== stateCookie(request))
return web("?error=invalid_state");
}

try {
const rawState =
method.type === "app_install"
? stateCookie(request)
: new URL(request.url).searchParams.get("state");
const carried = rawState
? verifySignedState(rawState, connectorStateSchema)
: null;
const codeVerifier = carried?.codeVerifier
? await decryptSecret(carried.codeVerifier)
: null;

const tokens = await exchangeCode(slug, method, {
code: callback.params.code ?? "",
redirectUri: redirectUriFor(slug),
codeVerifier,
params: callback.params,
issuer: new URL(request.url).searchParams.get("iss"),
});

const identity = await probeIdentity(
slug,
method,
tokens.accessToken,
callback.params,
);

const result = await upsertConnection({
connector,
slug,
authMethod: method.type,
organizationId: callback.organizationId,
userId: callback.userId,
tokens,
identity,
});

if (result.conflict) {
const owner = result.conflict.ownerEmail
? `&owner=${encodeURIComponent(result.conflict.ownerEmail)}`
: "";
return web(`?error=account_already_linked${owner}`);
}
} catch (error) {
console.error(`[connectors/${slug}] callback failed:`, error);
return web("?error=token_exchange_failed");
}

return web("?connected=1");
}
Loading
Loading