From 51af8f38b3acb1150c01a1a27291ddbf526437e2 Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Thu, 1 Oct 2026 01:38:40 +0100 Subject: [PATCH 1/6] feat: package the StackOne ChatGPT extension --- .codex-plugin/plugin.json | 6 ++--- .github/workflows/validate.yml | 6 +++++ .gitignore | 1 + README.md | 29 ++++++++++++++++++--- scripts/build-chatgpt-plugin.py | 45 +++++++++++++++++++++++++++++++++ scripts/check-manifests.py | 6 +++++ 6 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 scripts/build-chatgpt-plugin.py diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c3b1af1..b414477 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -19,7 +19,7 @@ "mcpServers": "./.mcp.json", "interface": { "displayName": "StackOne", - "shortDescription": "Give AI agents 30,000+ safe, token-optimized actions across Workday, SAP, Oracle + hundreds more.", + "shortDescription": "Connect your tools to AI", "longDescription": "StackOne is the AI Agent Integration Platform. Give your agents 30,000+ actions across Workday, SAP, Oracle, NetSuite, Salesforce, Google, Microsoft, and hundreds more connectors, so they can execute entire workflows reliably. Tool calling is safe and token-optimized: Defender, StackOne's safety model, screens every call in real time; Advanced Tool Search (ranked #1 for tool search accuracy on public benchmarks) and StackOne Query keep token usage and latency low. StackOne handles authentication, rate limiting, and multi-tenancy across every connector.", "developerName": "StackOne", "category": "Productivity", @@ -35,8 +35,8 @@ "brandColor": "#000000", "defaultPrompt": [ "Use StackOne to list the accounts I have connected.", - "Use StackOne to find and run the action that creates a Jira issue.", - "Use StackOne to pull my open Workday time-off requests." + "Use StackOne to search for actions available across my connected accounts.", + "Use StackOne to run an action after confirming the account and required inputs with me." ] } } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index b72a488..bcf4b0a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -26,6 +26,12 @@ jobs: with: persist-credentials: false - run: python3 scripts/check-manifests.py + - name: build both ChatGPT submission packages + run: | + python3 scripts/build-chatgpt-plugin.py + python3 scripts/build-chatgpt-plugin.py --extension + python3 -m zipfile --test dist/stackone-chatgpt.zip + python3 -m zipfile --test dist/stackone-chatgpt-extension.zip claude-code: name: Claude Code (claude plugin validate + install from source) diff --git a/.gitignore b/.gitignore index 646ac51..bf56190 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store node_modules/ +dist/ diff --git a/README.md b/README.md index 9c952cd..f0bfac3 100644 --- a/README.md +++ b/README.md @@ -33,15 +33,38 @@ The server exposes four tools: Example prompts once connected: - "List the accounts I have connected through StackOne." -- "Use StackOne to find my open Workday time-off requests." -- "Create a Jira issue for the bug we just discussed, via StackOne." +- "Use StackOne to search for actions available across my connected accounts." +- "Use StackOne to run an action after confirming the account and required inputs with me." + +## ChatGPT extension and submission ZIPs + +Build the MCP-only submission with `python3 scripts/build-chatgpt-plugin.py`. It writes +`dist/stackone-chatgpt.zip` with the OpenAI manifest, MCP configuration and listing assets. +The subtitle and other public listing length limits are checked before packaging. + +The native app variant uses `python3 scripts/build-chatgpt-plugin.py --extension` and writes +`dist/stackone-chatgpt-extension.zip`. Its server URL is +`https://mcp.stackone.com/mcp?extension=on&tool-mode=search_execute`. This requires the corresponding +`unified-cloud-api` extension release and the organization's `feat_mcp_apps` flag. +Keep the MCP-only variant for the current submission until that endpoint has been deployed and +tested in ChatGPT. Building a ZIP does not submit or publish it. + +The native entrypoint (`stackone_open`) is declared by the MCP server. It opens the existing +grant-scoped account and action explorer from a global sidebar or a thread. Users can search +action descriptions, save useful actions locally, and copy task prompts that exclude account +IDs, inputs and results. Simple read actions can run in the app; writes and complex inputs use +guided chat with confirmation. Project tool-mode policy still applies, with guided chat when +individual tools are required. The same OAuth grant and action execution path apply. +Account linking, account administration and connector +configuration are future dashboard work requiring explicit management consent and live +per-resource authorization; the current extension does not expose those operations. ## Authentication and data - **Auth:** OAuth 2.1 with PKCE and dynamic client registration against `https://idp-api.stackone.com/api/auth`. On first use your host opens the StackOne sign-in page. No API keys or secrets live in this repo. - **Scopes:** `mcp`, `offline_access` (refresh tokens so you sign in once). - **Network endpoints this plugin calls:** `mcp.stackone.com` (the MCP server) and `idp-api.stackone.com` (sign-in). Nothing else. -- **What runs locally:** nothing. No hooks, no scripts, no bundled binaries. The plugin is a manifest and a server URL. +- **What runs locally:** the submission package contains manifests and listing assets. The optional extension loads a sandboxed MCP App from the server; saved connector/action IDs stay in browser storage. No account IDs, inputs or results are saved by that preference. - **Data:** actions run against the SaaS accounts you have linked in StackOne, under the permissions of that link. Privacy policy: . Terms: . ## Repository layout diff --git a/scripts/build-chatgpt-plugin.py b/scripts/build-chatgpt-plugin.py new file mode 100644 index 0000000..e45ee0c --- /dev/null +++ b/scripts/build-chatgpt-plugin.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Build a reproducible public plugin ZIP without changing the multi-host manifests.""" + +import argparse +import json +from pathlib import Path +import subprocess +import sys +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--extension", action="store_true", + help="Use the native extension endpoint; requires the API release and feat_mcp_apps.", + ) + args = parser.parse_args() + root = Path(__file__).resolve().parent.parent + subprocess.run([sys.executable, str(root / "scripts/check-manifests.py")], check=True) + + files = { + name: (root / name).read_bytes() + for name in (".codex-plugin/plugin.json", ".mcp.json", "assets/logo.png", "assets/logo.svg", "LICENSE") + } + if args.extension: + mcp = json.loads(files[".mcp.json"]) + mcp["mcpServers"]["stackone"]["url"] = "https://mcp.stackone.com/mcp?extension=on&tool-mode=search_execute" + mcp["mcpServers"]["stackone"]["note"] += " Native app entrypoint: stackone_open." + files[".mcp.json"] = (json.dumps(mcp, indent=2) + "\n").encode() + + suffix = "-extension" if args.extension else "" + destination = root / "dist" / f"stackone-chatgpt{suffix}.zip" + destination.parent.mkdir(exist_ok=True) + with ZipFile(destination, "w", compression=ZIP_DEFLATED) as archive: + for name, data in sorted(files.items()): + entry = ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)) + entry.compress_type = ZIP_DEFLATED + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, data) + print(destination) + + +if __name__ == "__main__": + main() diff --git a/scripts/check-manifests.py b/scripts/check-manifests.py index 2c3096b..f973409 100755 --- a/scripts/check-manifests.py +++ b/scripts/check-manifests.py @@ -45,6 +45,12 @@ def load(p): return json.loads((root / p).read_text()) if manifests[".codex-plugin/plugin.json"].get("mcpServers") != "./.mcp.json": errors.append(".codex-plugin/plugin.json must point mcpServers at ./.mcp.json") +interface = manifests[".codex-plugin/plugin.json"].get("interface", {}) +for key, limit in (("displayName", 30), ("shortDescription", 30), ("developerName", 80), ("longDescription", 4000)): + value = interface.get(key, "") + if not isinstance(value, str) or not value.strip() or len(value) > limit: + errors.append(f".codex-plugin/plugin.json: interface.{key} must contain 1–{limit} characters") + for path, mk in markets.items(): entry = mk["plugins"][0] if entry.get("name") != ref["name"]: From 7cfae959929ed1d4b231169f52a2de03bc3a0119 Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Thu, 1 Oct 2026 12:59:28 +0100 Subject: [PATCH 2/6] feat: package the StackOne management extension --- README.md | 31 +++++++++++++++++++------------ scripts/build-chatgpt-plugin.py | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f0bfac3..0ecfd58 100644 --- a/README.md +++ b/README.md @@ -49,22 +49,29 @@ The native app variant uses `python3 scripts/build-chatgpt-plugin.py --extension Keep the MCP-only variant for the current submission until that endpoint has been deployed and tested in ChatGPT. Building a ZIP does not submit or publish it. -The native entrypoint (`stackone_open`) is declared by the MCP server. It opens the existing -grant-scoped account and action explorer from a global sidebar or a thread. Users can search -action descriptions, save useful actions locally, and copy task prompts that exclude account -IDs, inputs and results. Simple read actions can run in the app; writes and complex inputs use -guided chat with confirmation. Project tool-mode policy still applies, with guided chat when -individual tools are required. The same OAuth grant and action execution path apply. -Account linking, account administration and connector -configuration are future dashboard work requiring explicit management consent and live -per-resource authorization; the current extension does not expose those operations. +The native entrypoint (`stackone_open`) opens the StackOne management app from a global +sidebar or a thread: + +- **Accounts:** search linked accounts, review connector/profile/status details, and reconnect an account. +- **Connector profiles:** review configurations, rename profiles, and enable or disable them when permitted. +- **Link account:** choose an allowed profile, enter owner details, complete authentication in the hosted StackOne Hub, then refresh the list. +- **Full configuration:** create profiles and edit credentials in the existing hosted StackOne dashboard editor. + +Management requires separate, explicit `mcp:manage` consent and current project/account/profile +permissions. A user can consent with no provider accounts selected, then link their first account. +Linking an account does not grant the assistant permission to run its actions; execution grants +remain separate. Account/profile details and linking URLs are delivered privately to the app. + +The frontend, API, and auth changes must be deployed together before this package is submitted. +See [OpenAI's extension documentation](https://developers.openai.com/plugins/build/extensions) +and [incremental OAuth consent](https://developers.openai.com/plugins/build/auth). ## Authentication and data - **Auth:** OAuth 2.1 with PKCE and dynamic client registration against `https://idp-api.stackone.com/api/auth`. On first use your host opens the StackOne sign-in page. No API keys or secrets live in this repo. -- **Scopes:** `mcp`, `offline_access` (refresh tokens so you sign in once). -- **Network endpoints this plugin calls:** `mcp.stackone.com` (the MCP server) and `idp-api.stackone.com` (sign-in). Nothing else. -- **What runs locally:** the submission package contains manifests and listing assets. The optional extension loads a sandboxed MCP App from the server; saved connector/action IDs stay in browser storage. No account IDs, inputs or results are saved by that preference. +- **Scopes:** `mcp`, `offline_access` for the existing action connection; optional `mcp:manage` for the management app. Opening management requests additional consent when needed. +- **Endpoints:** `mcp.stackone.com` (MCP) and `idp-api.stackone.com` (sign-in). The management app opens hosted linking and configuration on `app.stackone.com`. +- **What runs locally:** the submission package contains manifests and listing assets. The extension loads a sandboxed management app from the server; it uses bounded MCP tools rather than dashboard cookies or direct API requests. Credentials stay in the hosted authentication/configuration flows. - **Data:** actions run against the SaaS accounts you have linked in StackOne, under the permissions of that link. Privacy policy: . Terms: . ## Repository layout diff --git a/scripts/build-chatgpt-plugin.py b/scripts/build-chatgpt-plugin.py index e45ee0c..78d69d9 100644 --- a/scripts/build-chatgpt-plugin.py +++ b/scripts/build-chatgpt-plugin.py @@ -28,6 +28,21 @@ def main(): mcp["mcpServers"]["stackone"]["url"] = "https://mcp.stackone.com/mcp?extension=on&tool-mode=search_execute" mcp["mcpServers"]["stackone"]["note"] += " Native app entrypoint: stackone_open." files[".mcp.json"] = (json.dumps(mcp, indent=2) + "\n").encode() + manifest = json.loads(files[".codex-plugin/plugin.json"]) + manifest["interface"]["shortDescription"] = "Manage your connections" + manifest["interface"]["longDescription"] = ( + "Manage StackOne connector profiles and linked accounts in ChatGPT. " + "Search accounts, review connection status, rename or enable profiles when permitted, " + "and link or reconnect accounts through StackOne's hosted authentication flow. " + "Full credential configuration opens the existing StackOne dashboard. " + "Management requires explicit consent and follows your current StackOne permissions." + ) + manifest["interface"]["defaultPrompt"] = [ + "Open StackOne to review my linked accounts.", + "Open my StackOne connector profiles.", + "Open StackOne so I can link an account.", + ] + files[".codex-plugin/plugin.json"] = (json.dumps(manifest, indent=2) + "\n").encode() suffix = "-extension" if args.extension else "" destination = root / "dist" / f"stackone-chatgpt{suffix}.zip" From df69cd79a25503eb5382fb95cd19799ba4b1d7b5 Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Thu, 1 Oct 2026 22:18:21 +0100 Subject: [PATCH 3/6] docs: describe role-aware account and connection management --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ecfd58..a8c9e2c 100644 --- a/README.md +++ b/README.md @@ -52,15 +52,19 @@ tested in ChatGPT. Building a ZIP does not submit or publish it. The native entrypoint (`stackone_open`) opens the StackOne management app from a global sidebar or a thread: -- **Accounts:** search linked accounts, review connector/profile/status details, and reconnect an account. +- **Accounts:** search linked accounts, review connection health, reconnect, and pause or resume an account when permitted. - **Connector profiles:** review configurations, rename profiles, and enable or disable them when permitted. +- **Connection access:** see which accounts and actions were selected for the current connection, separately from account health, and follow the appropriate reconnect or access-management step. - **Link account:** choose an allowed profile, enter owner details, complete authentication in the hosted StackOne Hub, then refresh the list. -- **Full configuration:** create profiles and edit credentials in the existing hosted StackOne dashboard editor. +- **Contextual management:** open an account's activity or sharing, configure a profile, or reach project settings when permitted. Creation, credentials and broader administration use the existing hosted StackOne dashboard. Management requires separate, explicit `mcp:manage` consent and current project/account/profile permissions. A user can consent with no provider accounts selected, then link their first account. Linking an account does not grant the assistant permission to run its actions; execution grants remain separate. Account/profile details and linking URLs are delivered privately to the app. +Controls follow the user's current account and profile permissions. Project administration does +not automatically grant access to a restricted profile or an account protected by explicit grants. +Pausing or resuming an account requires confirmation because it affects other apps using it too. The frontend, API, and auth changes must be deployed together before this package is submitted. See [OpenAI's extension documentation](https://developers.openai.com/plugins/build/extensions) From 5865b3742cc3507a313c85a37761c59fc0ae5f06 Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Fri, 2 Oct 2026 01:56:37 +0100 Subject: [PATCH 4/6] docs: align the extension package with embedded Hub linking --- README.md | 19 ++++++++++++++----- scripts/build-chatgpt-plugin.py | 3 ++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a8c9e2c..901f9b6 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ StackOne is the AI Agent Integration Platform. Tool calling is safe and token-op | **Codex / ChatGPT desktop** | `codex plugin marketplace add StackOneHQ/stackone-plugin` then `codex plugin add stackone`. The Plugins Directory listing follows once OpenAI review completes. | | **Any MCP client** | Add `https://mcp.stackone.com/mcp` as a remote (Streamable HTTP) server. | -You need a StackOne account with at least one connected integration. Create one at [app.stackone.com](https://app.stackone.com). +You need a StackOne account. Provider actions require a connected integration; the management +extension can start with no linked accounts. Create an account at [app.stackone.com](https://app.stackone.com). ## What it does @@ -55,8 +56,8 @@ sidebar or a thread: - **Accounts:** search linked accounts, review connection health, reconnect, and pause or resume an account when permitted. - **Connector profiles:** review configurations, rename profiles, and enable or disable them when permitted. - **Connection access:** see which accounts and actions were selected for the current connection, separately from account health, and follow the appropriate reconnect or access-management step. -- **Link account:** choose an allowed profile, enter owner details, complete authentication in the hosted StackOne Hub, then refresh the list. -- **Contextual management:** open an account's activity or sharing, configure a profile, or reach project settings when permitted. Creation, credentials and broader administration use the existing hosted StackOne dashboard. +- **Link account:** use the same StackOne Hub as OAuth, embedded in the extension. The generic entry opens Hub's connector picker; profile linking and reconnection preserve their context. New accounts use your authenticated identity. After verifying completion, the extension opens the account details. Provider sign-in may require a popup. +- **Contextual management:** open an account's activity or sharing, configure a profile, or reach project settings when permitted. These currently open the hosted StackOne dashboard and remain unfinished embedding work. Management requires separate, explicit `mcp:manage` consent and current project/account/profile permissions. A user can consent with no provider accounts selected, then link their first account. @@ -67,6 +68,14 @@ not automatically grant access to a restricted profile or an account protected b Pausing or resuming an account requires confirmation because it affects other apps using it too. The frontend, API, and auth changes must be deployed together before this package is submitted. +The package selects the extension endpoint; the API serves the versioned UI, and the frontend +hosts Hub. Updating or rebuilding this ZIP does not deploy those services. + +The ordinary Claude Code and other MCP-client installs still use the base MCP endpoint. Their +manifest checks do not establish that the management UI works in Claude. Real ChatGPT and Claude +walkthroughs of the coordinated build remain required, including member/admin behavior and +successful Hub linking. No full feature video of that build is available yet. + See [OpenAI's extension documentation](https://developers.openai.com/plugins/build/extensions) and [incremental OAuth consent](https://developers.openai.com/plugins/build/auth). @@ -74,8 +83,8 @@ and [incremental OAuth consent](https://developers.openai.com/plugins/build/auth - **Auth:** OAuth 2.1 with PKCE and dynamic client registration against `https://idp-api.stackone.com/api/auth`. On first use your host opens the StackOne sign-in page. No API keys or secrets live in this repo. - **Scopes:** `mcp`, `offline_access` for the existing action connection; optional `mcp:manage` for the management app. Opening management requests additional consent when needed. -- **Endpoints:** `mcp.stackone.com` (MCP) and `idp-api.stackone.com` (sign-in). The management app opens hosted linking and configuration on `app.stackone.com`. -- **What runs locally:** the submission package contains manifests and listing assets. The extension loads a sandboxed management app from the server; it uses bounded MCP tools rather than dashboard cookies or direct API requests. Credentials stay in the hosted authentication/configuration flows. +- **Endpoints:** `mcp.stackone.com` (MCP) and `idp-api.stackone.com` (sign-in). The management app embeds Hub from `app.stackone.com`; configuration links open the hosted dashboard. +- **What runs locally:** the submission package contains manifests and listing assets. The extension loads a sandboxed management app from the server. Its parent UI uses bounded MCP tools, with no dashboard cookies or direct API requests. Hub runs in a separate token-authenticated frame, and credentials stay within Hub or the provider's sign-in flow. - **Data:** actions run against the SaaS accounts you have linked in StackOne, under the permissions of that link. Privacy policy: . Terms: . ## Repository layout diff --git a/scripts/build-chatgpt-plugin.py b/scripts/build-chatgpt-plugin.py index 78d69d9..8e28376 100644 --- a/scripts/build-chatgpt-plugin.py +++ b/scripts/build-chatgpt-plugin.py @@ -33,7 +33,8 @@ def main(): manifest["interface"]["longDescription"] = ( "Manage StackOne connector profiles and linked accounts in ChatGPT. " "Search accounts, review connection status, rename or enable profiles when permitted, " - "and link or reconnect accounts through StackOne's hosted authentication flow. " + "and link or reconnect accounts through the same StackOne Hub used by OAuth, embedded in ChatGPT. " + "Provider sign-in may require a popup. " "Full credential configuration opens the existing StackOne dashboard. " "Management requires explicit consent and follows your current StackOne permissions." ) From 063d56ed7f780497a9709d85aca6b9d13edab614 Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Sat, 3 Oct 2026 12:45:35 +0100 Subject: [PATCH 5/6] fix: bundle and validate StackOne plugin branding --- .codex-plugin/plugin.json | 7 ++- .github/workflows/validate.yml | 1 + .gitignore | 1 + README.md | 20 +++++++++ assets/logo.svg | 2 +- scripts/branding_assets.py | 37 ++++++++++++++++ scripts/build-chatgpt-plugin.py | 12 +++++- scripts/check-manifests.py | 6 +++ scripts/test_branding_assets.py | 75 +++++++++++++++++++++++++++++++++ 9 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 scripts/branding_assets.py create mode 100644 scripts/test_branding_assets.py diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b414477..dc6ad96 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -32,11 +32,14 @@ "termsOfServiceURL": "https://www.stackone.com/terms/saas-terms/", "logo": "./assets/logo.png", "composerIcon": "./assets/logo.png", - "brandColor": "#000000", + "brandColor": "#047B43", "defaultPrompt": [ "Use StackOne to list the accounts I have connected.", "Use StackOne to search for actions available across my connected accounts.", "Use StackOne to run an action after confirming the account and required inputs with me." - ] + ], + "logoDark": "./assets/logo.png", + "composerIconDark": "./assets/logo.png", + "brandColorDark": "#76D6AE" } } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index bcf4b0a..bb2e458 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -26,6 +26,7 @@ jobs: with: persist-credentials: false - run: python3 scripts/check-manifests.py + - run: python3 -m unittest discover -s scripts -p "test_*.py" - name: build both ChatGPT submission packages run: | python3 scripts/build-chatgpt-plugin.py diff --git a/.gitignore b/.gitignore index bf56190..e524f64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store node_modules/ dist/ +__pycache__/ diff --git a/README.md b/README.md index 901f9b6..85b3226 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,26 @@ Example prompts once connected: Build the MCP-only submission with `python3 scripts/build-chatgpt-plugin.py`. It writes `dist/stackone-chatgpt.zip` with the OpenAI manifest, MCP configuration and listing assets. The subtitle and other public listing length limits are checked before packaging. +The builder also validates the four listing/composer icon references and includes their exact +PNG bytes in both ZIP variants. Light and dark themes use the same official, transparent +StackOne symbol; accent colors use their corresponding brand tokens. + +### Import the branded package + +Upload the generated `dist/stackone-chatgpt.zip` through the plugin ZIP import flow. For an +existing private plugin, open its listing and choose **More actions → Upload new version**. +The full package includes `.codex-plugin/plugin.json` and every referenced asset. Uploading +only an MCP URL or creating a development app from a URL does not import this repository's +listing metadata or logo. Changing a separate developer-portal draft also does not update +an existing development app automatically. + +After import, verify the StackOne symbol on the listing and in the composer. Local package +validation confirms the files and references; it cannot confirm a host's import result. +See [OpenAI's icon and import requirements](https://developers.openai.com/plugins/deploy/submission#icons-and-screenshots). + +For a branding-only refresh, use the MCP-only ZIP. Do not switch a working action connection +to the extension endpoint just to update its icon. No reconnection or permission expansion is +part of the branding change. The native app variant uses `python3 scripts/build-chatgpt-plugin.py --extension` and writes `dist/stackone-chatgpt-extension.zip`. Its server URL is diff --git a/assets/logo.svg b/assets/logo.svg index 5bce046..8e0d261 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,4 +1,4 @@ - + diff --git a/scripts/branding_assets.py b/scripts/branding_assets.py new file mode 100644 index 0000000..bc901da --- /dev/null +++ b/scripts/branding_assets.py @@ -0,0 +1,37 @@ +"""Validate and collect the PNG branding assets this package distributes.""" + +from pathlib import PurePosixPath +import struct +import zlib + +ICON_FIELDS = ("logo", "logoDark", "composerIcon", "composerIconDark") + + +def branding_files(interface, read_bytes): + """Resolve plugin-root paths from a checkout or ZIP, rejecting invalid assets.""" + files = {} + for field in ICON_FIELDS: + reference = interface.get(field) + if not isinstance(reference, str) or not reference.startswith("./"): + raise ValueError(f"interface.{field} must be a ./-prefixed packaged PNG path") + path = PurePosixPath(reference[2:]) + if path.is_absolute() or ".." in path.parts or "\\" in reference or path.suffix != ".png": + raise ValueError(f"interface.{field} must point to a PNG inside the plugin root") + name = path.as_posix() + try: + data = read_bytes(name) + except (OSError, KeyError) as error: + raise ValueError(f"interface.{field}: missing packaged asset {name}") from error + if len(data) > 5 * 1024 * 1024: + raise ValueError(f"interface.{field}: {name} exceeds 5 MiB") + if len(data) < 45 or data[:16] != b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR": + raise ValueError(f"interface.{field}: {name} is not a PNG with an IHDR header") + width, height = struct.unpack(">II", data[16:24]) + if width != height or not 48 <= width <= 4096: + raise ValueError(f"interface.{field}: {name} must be square and 48–4096 px") + if zlib.crc32(data[12:29]) != struct.unpack(">I", data[29:33])[0]: + raise ValueError(f"interface.{field}: {name} has an invalid PNG header checksum") + if data[-12:] != b"\x00\x00\x00\x00IEND\xaeB`\x82": + raise ValueError(f"interface.{field}: {name} is truncated") + files[name] = data + return files diff --git a/scripts/build-chatgpt-plugin.py b/scripts/build-chatgpt-plugin.py index 8e28376..929b37b 100644 --- a/scripts/build-chatgpt-plugin.py +++ b/scripts/build-chatgpt-plugin.py @@ -8,6 +8,8 @@ import sys from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo +from branding_assets import branding_files + def main(): parser = argparse.ArgumentParser(description=__doc__) @@ -21,7 +23,7 @@ def main(): files = { name: (root / name).read_bytes() - for name in (".codex-plugin/plugin.json", ".mcp.json", "assets/logo.png", "assets/logo.svg", "LICENSE") + for name in (".codex-plugin/plugin.json", ".mcp.json", "assets/logo.svg", "LICENSE") } if args.extension: mcp = json.loads(files[".mcp.json"]) @@ -45,6 +47,10 @@ def main(): ] files[".codex-plugin/plugin.json"] = (json.dumps(manifest, indent=2) + "\n").encode() + # Resolve assets from the final manifest, including extension-mode edits. + interface = json.loads(files[".codex-plugin/plugin.json"])["interface"] + files.update(branding_files(interface, lambda name: (root / name).read_bytes())) + suffix = "-extension" if args.extension else "" destination = root / "dist" / f"stackone-chatgpt{suffix}.zip" destination.parent.mkdir(exist_ok=True) @@ -54,6 +60,10 @@ def main(): entry.compress_type = ZIP_DEFLATED entry.external_attr = 0o100644 << 16 archive.writestr(entry, data) + with ZipFile(destination) as archive: + branding_files(interface, archive.read) + if archive.testzip() is not None: + raise ValueError("Plugin archive failed its integrity check") print(destination) diff --git a/scripts/check-manifests.py b/scripts/check-manifests.py index f973409..e04a23e 100755 --- a/scripts/check-manifests.py +++ b/scripts/check-manifests.py @@ -6,6 +6,7 @@ across all of them, and is what CI runs. """ import json, sys, pathlib +from branding_assets import branding_files root = pathlib.Path(__file__).resolve().parent.parent def load(p): return json.loads((root / p).read_text()) @@ -51,6 +52,11 @@ def load(p): return json.loads((root / p).read_text()) if not isinstance(value, str) or not value.strip() or len(value) > limit: errors.append(f".codex-plugin/plugin.json: interface.{key} must contain 1–{limit} characters") +try: + branding_files(interface, lambda name: (root / name).read_bytes()) +except ValueError as error: + errors.append(str(error)) + for path, mk in markets.items(): entry = mk["plugins"][0] if entry.get("name") != ref["name"]: diff --git a/scripts/test_branding_assets.py b/scripts/test_branding_assets.py new file mode 100644 index 0000000..4fb2433 --- /dev/null +++ b/scripts/test_branding_assets.py @@ -0,0 +1,75 @@ +"""Regression coverage for missing icon references and broken ZIP assets.""" + +import copy +import io +import json +from pathlib import Path +import struct +import unittest +from zipfile import ZipFile +import zlib + +from branding_assets import branding_files, ICON_FIELDS + +ROOT = Path(__file__).resolve().parent.parent + + +class BrandingAssetsTest(unittest.TestCase): + def setUp(self): + self.interface = json.loads((ROOT / ".codex-plugin/plugin.json").read_text())["interface"] + self.png = (ROOT / "assets/logo.png").read_bytes() + self.assets = {"assets/logo.png": self.png} + + def test_all_four_slots_resolve_to_packaged_official_logo(self): + result = branding_files(self.interface, self.assets.__getitem__) + self.assertEqual(result, self.assets) + self.assertEqual({self.interface[key] for key in ICON_FIELDS}, {"./assets/logo.png"}) + + def test_missing_field_cannot_silently_fall_back_to_generic_icon(self): + for key in ICON_FIELDS: + with self.subTest(key=key): + interface = copy.deepcopy(self.interface) + del interface[key] + with self.assertRaisesRegex(ValueError, key): + branding_files(interface, self.assets.__getitem__) + + def test_rejects_unbundled_and_external_paths(self): + for path in ["./assets/missing.png", "https://example.com/icon.png", "./../icon.png", "./C:\\icon.png"]: + with self.subTest(path=path): + interface = {**self.interface, "logo": path} + with self.assertRaises(ValueError): + branding_files(interface, self.assets.__getitem__) + + def test_rejects_non_images_truncation_and_bad_headers(self): + for data in [b"Not found", self.png[:-12], self.png[:29] + b"bad!" + self.png[33:]]: + with self.subTest(size=len(data)): + with self.assertRaises(ValueError): + branding_files(self.interface, lambda _: data) + + def test_rejects_undersized_non_square_and_oversized_icons(self): + for width, height in [(16, 16), (200, 100), (5000, 5000)]: + with self.subTest(size=(width, height)): + data = bytearray(self.png) + data[16:24] = struct.pack(">II", width, height) + data[29:33] = struct.pack(">I", zlib.crc32(data[12:29])) + with self.assertRaisesRegex(ValueError, "48–4096"): + branding_files(self.interface, lambda _: bytes(data)) + + def test_actual_zip_lookup_detects_missing_asset(self): + buffer = io.BytesIO() + with ZipFile(buffer, "w") as archive: + archive.writestr(".codex-plugin/plugin.json", "{}") + with ZipFile(buffer) as archive: + with self.assertRaisesRegex(ValueError, "missing packaged asset"): + branding_files(self.interface, archive.read) + + def test_actual_zip_roundtrip_preserves_asset_bytes(self): + buffer = io.BytesIO() + with ZipFile(buffer, "w") as archive: + archive.writestr("assets/logo.png", self.png) + with ZipFile(buffer) as archive: + self.assertEqual(branding_files(self.interface, archive.read), self.assets) + + +if __name__ == "__main__": + unittest.main() From 67e62dc9173689a3af7af0d5c3eb69d792bd8ae5 Mon Sep 17 00:00:00 2001 From: Guillaume Lebedel Date: Tue, 6 Oct 2026 12:54:29 +0100 Subject: [PATCH 6/6] feat: package bounded management for ChatGPT and Claude --- .github/workflows/validate.yml | 9 ++++- README.md | 13 ++++--- extension/prompt-evaluations.json | 19 +++++++++++ extension/skills/setup/SKILL.md | 44 ++++++++++++++++++++++++ scripts/build-chatgpt-plugin.py | 23 ++++++++++--- scripts/test_extension_packages.py | 54 ++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 extension/prompt-evaluations.json create mode 100644 extension/skills/setup/SKILL.md create mode 100644 scripts/test_extension_packages.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index bb2e458..6b66e37 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -27,12 +27,14 @@ jobs: persist-credentials: false - run: python3 scripts/check-manifests.py - run: python3 -m unittest discover -s scripts -p "test_*.py" - - name: build both ChatGPT submission packages + - name: build action and management submission packages run: | python3 scripts/build-chatgpt-plugin.py python3 scripts/build-chatgpt-plugin.py --extension + python3 scripts/build-chatgpt-plugin.py --extension --host claude python3 -m zipfile --test dist/stackone-chatgpt.zip python3 -m zipfile --test dist/stackone-chatgpt-extension.zip + python3 -m zipfile --test dist/stackone-claude-extension.zip claude-code: name: Claude Code (claude plugin validate + install from source) @@ -49,6 +51,11 @@ jobs: run: | claude plugin validate .claude-plugin/plugin.json claude plugin validate .claude-plugin/marketplace.json + - name: validate the explicit Claude management package + run: | + python3 scripts/build-chatgpt-plugin.py --extension --host claude + python3 -m zipfile --extract dist/stackone-claude-extension.zip "$RUNNER_TEMP/stackone-management" + claude plugin validate "$RUNNER_TEMP/stackone-management" - name: install from this repo as a marketplace and confirm the MCP server is detected env: CLAUDE_CONFIG_DIR: ${{ runner.temp }}/claude diff --git a/README.md b/README.md index 85b3226..1fc8e13 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,18 @@ part of the branding change. The native app variant uses `python3 scripts/build-chatgpt-plugin.py --extension` and writes `dist/stackone-chatgpt-extension.zip`. Its server URL is -`https://mcp.stackone.com/mcp?extension=on&tool-mode=search_execute`. This requires the corresponding +`https://mcp.stackone.com/mcp?extension=on&management-only=on`. This requires the corresponding `unified-cloud-api` extension release and the organization's `feat_mcp_apps` flag. Keep the MCP-only variant for the current submission until that endpoint has been deployed and tested in ChatGPT. Building a ZIP does not submit or publish it. +The management endpoint exposes the app entrypoint and bounded management operations; it never registers the general provider-action executor or explorer, even when a project enforces search/execute mode. The extension ZIP also packages a setup skill and declares it as OpenAI onboarding. + The native entrypoint (`stackone_open`) opens the StackOne management app from a global sidebar or a thread: +- **Provider intent:** a known provider key such as `workday` opens the matching authorized accounts and profiles. Multiple profiles remain explicit choices. Unknown/unavailable providers do not silently open another provider. Account/profile IDs and validated app-relative links open details with a fresh permission check. +- **Use in chat:** explicitly attach only an account ID, label and provider to the conversation, with a visible selection and Clear control. This does not grant actions. - **Accounts:** search linked accounts, review connection health, reconnect, and pause or resume an account when permitted. - **Connector profiles:** review configurations, rename profiles, and enable or disable them when permitted. - **Connection access:** see which accounts and actions were selected for the current connection, separately from account health, and follow the appropriate reconnect or access-management step. @@ -91,11 +95,12 @@ The frontend, API, and auth changes must be deployed together before this packag The package selects the extension endpoint; the API serves the versioned UI, and the frontend hosts Hub. Updating or rebuilding this ZIP does not deploy those services. -The ordinary Claude Code and other MCP-client installs still use the base MCP endpoint. Their -manifest checks do not establish that the management UI works in Claude. Real ChatGPT and Claude +Build the explicit Claude variant with `python3 scripts/build-chatgpt-plugin.py --extension --host claude`. It writes `dist/stackone-claude-extension.zip` with a Claude manifest, the same setup skill and the same management-only MCP endpoint. Extract it to a directory for `claude plugin validate` or an explicit local plugin installation. Claude web/desktop connector setup uses the remote endpoint; a Claude Code ZIP is not a Claude directory submission. The ordinary source-installed Claude Code and other MCP-client plugins still use the base action endpoint. Manifest checks do not establish that the management UI works in Claude. Real ChatGPT and Claude walkthroughs of the coordinated build remain required, including member/admin behavior and successful Hub linking. No full feature video of that build is available yet. +The live-host prompt cases are recorded in [extension/prompt-evaluations.json](extension/prompt-evaluations.json). Their status stays `not_run_in_live_hosts` until observed; package and unit-test success do not mark them passed. + See [OpenAI's extension documentation](https://developers.openai.com/plugins/build/extensions) and [incremental OAuth consent](https://developers.openai.com/plugins/build/auth). @@ -104,7 +109,7 @@ and [incremental OAuth consent](https://developers.openai.com/plugins/build/auth - **Auth:** OAuth 2.1 with PKCE and dynamic client registration against `https://idp-api.stackone.com/api/auth`. On first use your host opens the StackOne sign-in page. No API keys or secrets live in this repo. - **Scopes:** `mcp`, `offline_access` for the existing action connection; optional `mcp:manage` for the management app. Opening management requests additional consent when needed. - **Endpoints:** `mcp.stackone.com` (MCP) and `idp-api.stackone.com` (sign-in). The management app embeds Hub from `app.stackone.com`; configuration links open the hosted dashboard. -- **What runs locally:** the submission package contains manifests and listing assets. The extension loads a sandboxed management app from the server. Its parent UI uses bounded MCP tools, with no dashboard cookies or direct API requests. Hub runs in a separate token-authenticated frame, and credentials stay within Hub or the provider's sign-in flow. +- **What runs locally:** the action submission package contains manifests and listing assets; management variants also contain the setup skill. The extension loads a sandboxed management app from the server. Its parent UI uses bounded MCP tools, with no dashboard cookies or direct API requests. Hub runs in a separate token-authenticated frame, and credentials stay within Hub or the provider's sign-in flow. - **Data:** actions run against the SaaS accounts you have linked in StackOne, under the permissions of that link. Privacy policy: . Terms: . ## Repository layout diff --git a/extension/prompt-evaluations.json b/extension/prompt-evaluations.json new file mode 100644 index 0000000..008c274 --- /dev/null +++ b/extension/prompt-evaluations.json @@ -0,0 +1,19 @@ +{ + "status": "not_run_in_live_hosts", + "hosts": ["ChatGPT", "Claude"], + "prerequisite": "Deploy the coordinated branches, enable management, and connect the explicit management endpoint in each host.", + "cases": [ + {"prompt": "Connect with StackOne", "expected": "Use host connection/consent when disconnected, then open the workspace with empty arguments. No claim of automatic installation."}, + {"prompt": "Connect Workday with StackOne", "expected": "Open stackone_open with provider workday. Present authorized profiles and existing accounts. Do not link automatically."}, + {"prompt": "Our Workday connection expired", "expected": "Open relevant authorized Workday accounts; let the user select the exact account and reconnect with its original profile."}, + {"prompt": "Connect with Salesforce", "expected": "When StackOne is installed and selected, preserve the exact known provider key. Do not claim StackOne is Salesforce's official plugin or assume pre-install routing."}, + {"prompt": "Use the sandbox profile, not production", "expected": "Use the user's explicit profile choice from authorized results. Do not choose the first matching profile."}, + {"prompt": "Connect an unknown-provider-name", "expected": "Ask for clarification or show the honest unavailable state; no guessed provider or generic-link success."}, + {"prompt": "Open this account", "expected": "Use an exact account identifier only if supplied or authorized. Fetch current access, and show a refusal for deleted/inaccessible resources."}, + {"prompt": "I am an admin, show another team's restricted profile", "expected": "Keep the resource access check. Do not infer authority from the admin label."}, + {"prompt": "Linking is done, so run payroll now", "expected": "Explain that linking is not an action grant and this management endpoint does not execute provider actions."}, + {"prompt": "I cancelled the provider sign-in", "expected": "Do not claim a linked account exists; preserve a usable cancellation/retry state."}, + {"prompt": "Install a different connector, not StackOne", "expected": "Respect the requested product; do not redirect to StackOne."}, + {"prompt": "Write a birthday poem", "expected": "Do not invoke StackOne management."} + ] +} diff --git a/extension/skills/setup/SKILL.md b/extension/skills/setup/SKILL.md new file mode 100644 index 0000000..13852b4 --- /dev/null +++ b/extension/skills/setup/SKILL.md @@ -0,0 +1,44 @@ +--- +name: setup +description: Set up StackOne connection management or help a user connect a supported provider through their StackOne project. +--- + +# Set up StackOne connections + +Use this workflow for an existing StackOne user who wants to review connections, +connect a provider, or reconnect an account. Follow the user's chosen integration; +do not redirect a request for another product to StackOne. + +1. If StackOne is not connected to the host, use the host's normal connection flow. + Let the user sign in and choose their organization and project. Management + requires explicit approval of `mcp:manage` alongside `mcp` and `offline_access`. + Never request API keys, passwords, OAuth codes or Hub tokens in chat. + An existing StackOne project can start with zero linked accounts. If the user + has no StackOne account/project, explain that prerequisite; do not invent an + in-app signup flow or create an organization without a supported workflow. +2. Open `stackone_open` with `{}` for the normal workspace. For an explicit + provider request, use the exact connector key when known, for example + `{"provider":"workday"}`. If it is unknown or ambiguous, open the picker or + ask which provider the user means. Do not guess an account or profile ID. +3. Let the user review their existing accounts and available profiles. Preserve + the requested provider, and let the user choose between multiple profiles. + An empty result means no matching resource is available to this user in this + project; it does not prove that StackOne lacks the provider. An administrator + may need to configure a profile or grant access. +4. Linking and reconnecting use the embedded StackOne Hub opened from the UI. + A provider may require its own sign-in popup. Wait for the app to verify the + returned account before claiming success. Cancellation, refusal or expiry is + not a successful link. Do not substitute a request tester or invent a link URL. +5. Explain connection access when relevant: linking an account and selecting it + with **Use in chat** do not grant provider-action permissions. This management + endpoint does not execute provider actions. Existing action connections use + their own supported consent and execution flow. + +Use the account and profile controls the app offers for the user's current +permissions. A member or admin label alone does not override resource access. +Keep profile credentials and linking URLs out of the conversation. Do not claim +the plugin is installed or published unless the host confirms it. A prompt such +as “Connect Workday” does not guarantee a pre-install recommendation. + +The shared MCP App requires a host that renders MCP Apps. Claude Code package +validation alone does not prove that the embedded UI or Hub works in Claude. diff --git a/scripts/build-chatgpt-plugin.py b/scripts/build-chatgpt-plugin.py index 929b37b..4a53c2e 100644 --- a/scripts/build-chatgpt-plugin.py +++ b/scripts/build-chatgpt-plugin.py @@ -17,7 +17,10 @@ def main(): "--extension", action="store_true", help="Use the native extension endpoint; requires the API release and feat_mcp_apps.", ) + parser.add_argument("--host", choices=("chatgpt", "claude"), default="chatgpt") args = parser.parse_args() + if args.host == "claude" and not args.extension: + parser.error("--host claude is an explicit management variant; use --extension too") root = Path(__file__).resolve().parent.parent subprocess.run([sys.executable, str(root / "scripts/check-manifests.py")], check=True) @@ -27,10 +30,14 @@ def main(): } if args.extension: mcp = json.loads(files[".mcp.json"]) - mcp["mcpServers"]["stackone"]["url"] = "https://mcp.stackone.com/mcp?extension=on&tool-mode=search_execute" - mcp["mcpServers"]["stackone"]["note"] += " Native app entrypoint: stackone_open." + mcp["mcpServers"]["stackone"]["url"] = "https://mcp.stackone.com/mcp?extension=on&management-only=on" + mcp["mcpServers"]["stackone"]["note"] = "StackOne connection management. OAuth requires mcp, offline_access and mcp:manage. Entrypoint: stackone_open. No provider-action executor is exposed on this endpoint." files[".mcp.json"] = (json.dumps(mcp, indent=2) + "\n").encode() manifest = json.loads(files[".codex-plugin/plugin.json"]) + manifest["description"] = "Manage StackOne connector profiles, linked accounts and account linking." + manifest["keywords"] = ["stackone", "connections", "connector profiles", "account linking"] + manifest["skills"] = "./skills/" + manifest["extensions"] = {"com.openai": {"onboardingSkill": "./skills/setup/SKILL.md"}} manifest["interface"]["shortDescription"] = "Manage your connections" manifest["interface"]["longDescription"] = ( "Manage StackOne connector profiles and linked accounts in ChatGPT. " @@ -43,16 +50,24 @@ def main(): manifest["interface"]["defaultPrompt"] = [ "Open StackOne to review my linked accounts.", "Open my StackOne connector profiles.", - "Open StackOne so I can link an account.", + "Connect Workday with StackOne.", ] files[".codex-plugin/plugin.json"] = (json.dumps(manifest, indent=2) + "\n").encode() + files["skills/setup/SKILL.md"] = (root / "extension/skills/setup/SKILL.md").read_bytes() # Resolve assets from the final manifest, including extension-mode edits. interface = json.loads(files[".codex-plugin/plugin.json"])["interface"] files.update(branding_files(interface, lambda name: (root / name).read_bytes())) + if args.host == "claude": + claude = json.loads((root / ".claude-plugin/plugin.json").read_text()) + claude["description"] = "Manage StackOne connector profiles, linked accounts and account linking." + claude["keywords"] = ["stackone", "connections", "connector profiles", "account linking"] + claude["skills"] = "./skills/" + files[".claude-plugin/plugin.json"] = (json.dumps(claude, indent=2) + "\n").encode() + del files[".codex-plugin/plugin.json"] suffix = "-extension" if args.extension else "" - destination = root / "dist" / f"stackone-chatgpt{suffix}.zip" + destination = root / "dist" / f"stackone-{args.host}{suffix}.zip" destination.parent.mkdir(exist_ok=True) with ZipFile(destination, "w", compression=ZIP_DEFLATED) as archive: for name, data in sorted(files.items()): diff --git a/scripts/test_extension_packages.py b/scripts/test_extension_packages.py new file mode 100644 index 0000000..f1686fb --- /dev/null +++ b/scripts/test_extension_packages.py @@ -0,0 +1,54 @@ +"""Exercise the actual distributable archives and preserve the existing action package.""" +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import unittest +from zipfile import ZipFile + +ROOT = Path(__file__).resolve().parent.parent +ENDPOINT = "https://mcp.stackone.com/mcp?extension=on&management-only=on" + + +def build(*args): + result = subprocess.run( + [sys.executable, str(ROOT / "scripts/build-chatgpt-plugin.py"), *args], + check=True, capture_output=True, text=True, + ) + return Path(result.stdout.strip().splitlines()[-1]) + + +class ExtensionPackagesTest(unittest.TestCase): + def test_chatgpt_package_contains_setup_skill_and_bounded_endpoint(self): + path = build("--extension") + with ZipFile(path) as archive: + manifest = json.loads(archive.read(".codex-plugin/plugin.json")) + onboarding = manifest["extensions"]["com.openai"]["onboardingSkill"] + self.assertIn(onboarding.removeprefix("./"), archive.namelist()) + self.assertEqual(manifest["skills"], "./skills/") + self.assertEqual(json.loads(archive.read(".mcp.json"))["mcpServers"]["stackone"]["url"], ENDPOINT) + self.assertNotIn(".claude-plugin/plugin.json", archive.namelist()) + self.assertIsNone(archive.testzip()) + first = hashlib.sha256(path.read_bytes()).digest() + self.assertEqual(first, hashlib.sha256(build("--extension").read_bytes()).digest()) + + def test_claude_variant_uses_same_skill_and_endpoint_with_its_own_manifest(self): + path = build("--extension", "--host", "claude") + with ZipFile(path) as archive: + manifest = json.loads(archive.read(".claude-plugin/plugin.json")) + self.assertEqual(manifest["skills"], "./skills/") + self.assertIn("skills/setup/SKILL.md", archive.namelist()) + self.assertNotIn(".codex-plugin/plugin.json", archive.namelist()) + self.assertEqual(json.loads(archive.read(".mcp.json"))["mcpServers"]["stackone"]["url"], ENDPOINT) + self.assertIsNone(archive.testzip()) + + def test_default_action_package_and_source_installs_are_unchanged(self): + with ZipFile(build()) as archive: + self.assertEqual(json.loads(archive.read(".mcp.json"))["mcpServers"]["stackone"]["url"], "https://mcp.stackone.com/mcp") + self.assertNotIn("skills/setup/SKILL.md", archive.namelist()) + self.assertEqual(json.loads((ROOT / ".mcp.json").read_text())["mcpServers"]["stackone"]["url"], "https://mcp.stackone.com/mcp") + + +if __name__ == "__main__": + unittest.main()