From 48f1176d76eafb34a6a62c9931652e79325a4332 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Fri, 14 Aug 2026 11:46:29 -0400 Subject: [PATCH 1/4] feat: prepare Claude and Codex plugins for the OpenAI directory Add a real plugin.json next to the existing marketplace catalog, ship the pixel chevron as the listing mark, advertise MCP tool safety hints and OAuth security schemes, and serve the OpenAI domain-verification challenge. --- .changeset/mcp-tool-annotations.md | 5 ++ .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 22 ++++++ .codex-plugin/plugin.json | 22 +++++- README.md | 4 +- apps/mcp/.dev.vars.example | 5 ++ apps/mcp/src/env.d.ts | 5 ++ apps/mcp/src/index.ts | 12 +++ apps/mcp/src/robots.ts | 1 + apps/mcp/src/tools.ts | 75 +++++++++++++++++- apps/mcp/test/mcp.test.ts | 71 ++++++++++++++++- apps/mcp/wrangler.jsonc | 3 + apps/web/src/pages/docs/agents.astro | 6 +- assets/logo.png | Bin 0 -> 1317 bytes packages/uploads/src/mcp/server.ts | 89 ++++++++++++++++++++++ packages/uploads/src/mcp/tools.ts | 81 +++++++++++++++++++- packages/uploads/test/mcp.test.ts | 34 +++++++++ plugins/claude/uploads/README.md | 8 +- plugins/claude/uploads/commands/attach.md | 4 +- plugins/codex/README.md | 8 +- scripts/og/render-og.mjs | 23 ++++++ 21 files changed, 463 insertions(+), 19 deletions(-) create mode 100644 .changeset/mcp-tool-annotations.md create mode 100644 .claude-plugin/plugin.json create mode 100644 assets/logo.png diff --git a/.changeset/mcp-tool-annotations.md b/.changeset/mcp-tool-annotations.md new file mode 100644 index 00000000..bd224942 --- /dev/null +++ b/.changeset/mcp-tool-annotations.md @@ -0,0 +1,5 @@ +--- +"@buildinternet/uploads": patch +--- + +Advertise safety hints and OAuth security schemes on every MCP tool, and return a www-authenticate challenge when a token is missing the required scope. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0049c92c..c5aaafeb 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "uploads", "source": "./", - "description": "Get screenshots, GIFs, recordings, and files into GitHub PRs and issues via uploads.sh. Bundles the github-screenshots, annotate-screenshots, and uploads-cli skills, a local MCP server, and the /uploads:attach command.", + "description": "Get screenshots, GIFs, recordings, and files into GitHub PRs and issues via uploads.sh. Bundles the github-screenshots, annotate-screenshots, and uploads-cli skills, the hosted MCP server, and the /uploads:attach command.", "version": "0.2.0", "author": { "name": "Build Internet" @@ -17,8 +17,8 @@ "homepage": "https://uploads.sh", "repository": "https://github.com/buildinternet/uploads", "license": "Apache-2.0", + "icon": "./assets/logo.png", "keywords": ["uploads", "screenshots", "github", "file-hosting", "images", "mcp"], - "strict": false, "skills": [ "./skills/github-screenshots", "./skills/annotate-screenshots", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000..57eb1734 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "name": "uploads", + "version": "0.2.0", + "description": "Host screenshots, GIFs, recordings, and files on uploads.sh and embed them in GitHub PRs and issues.", + "author": { + "name": "Build Internet", + "url": "https://uploads.sh" + }, + "homepage": "https://uploads.sh", + "repository": "https://github.com/buildinternet/uploads", + "license": "Apache-2.0", + "icon": "./assets/logo.png", + "keywords": ["uploads", "screenshots", "github", "file-hosting", "images", "mcp"], + "skills": [ + "./skills/github-screenshots", + "./skills/annotate-screenshots", + "./skills/uploads-cli" + ], + "commands": "./plugins/claude/uploads/commands", + "mcpServers": "./plugins/claude/uploads/.mcp.json", + "hooks": "./hooks/hooks.json" +} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index ad4860aa..48fca5bf 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -15,5 +15,25 @@ "./skills/annotate-screenshots", "./skills/uploads-cli" ], - "hooks": "./hooks/hooks.json" + "mcpServers": "./plugins/claude/uploads/.mcp.json", + "hooks": "./hooks/hooks.json", + "interface": { + "displayName": "uploads.sh", + "shortDescription": "Host files and attach them to GitHub PRs and issues", + "longDescription": "Host screenshots, GIFs, recordings, and files on uploads.sh and embed them in GitHub PRs and issues. Bundles the github-screenshots, annotate-screenshots, and uploads-cli skills, the hosted MCP server at agents.uploads.sh, and a pre-PR screenshot reminder hook.", + "developerName": "Build Internet", + "category": "Productivity", + "capabilities": ["Read", "Write"], + "websiteURL": "https://uploads.sh", + "privacyPolicyURL": "https://uploads.sh/privacy", + "termsOfServiceURL": "https://uploads.sh/terms", + "defaultPrompt": [ + "Attach this screenshot to the current pull request", + "Stage a before/after of the settings page for this branch", + "Give me a public URL for this image" + ], + "brandColor": "#c27eff", + "composerIcon": "./assets/logo.png", + "logo": "./assets/logo.png" + } } diff --git a/README.md b/README.md index 3b66c4a4..a95dadb4 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,9 @@ REST routes are in [docs/api.md](docs/api.md). | `skills/uploads-cli/` | Agent skill for driving the CLI | | `hooks/` | Shared pre-PR screenshot hook (`uploads hook pre-pr-screenshot`) for Claude + Codex | | `plugins/claude/` | Claude Code plugin config (skills path, MCP, commands) | -| `.codex-plugin/` | Codex plugin manifest — same skills + shared hook | +| `.claude-plugin/` | Claude marketplace catalog + plugin manifest | +| `.codex-plugin/` | Codex plugin manifest — skills, hosted MCP, and shared hook | +| `assets/logo.png` | Pixel chevron mark for the Codex / OpenAI plugin listing | The workers and web app are separate deployables. All storage access goes through `createStorage()` in `packages/storage` — adding a provider is one new diff --git a/apps/mcp/.dev.vars.example b/apps/mcp/.dev.vars.example index 6fa9c111..30e98565 100644 --- a/apps/mcp/.dev.vars.example +++ b/apps/mcp/.dev.vars.example @@ -7,3 +7,8 @@ # port and apps/web's UPLOADS_AUTH_ORIGIN convention (cookies/redirect_uri # treat the two as different sites). AUTH_ORIGIN=http://127.0.0.1:8788 + +# Optional. Public OpenAI plugin domain-verification token, served at +# /.well-known/openai-apps-challenge. Leave unset locally unless you are +# testing that route. +# OPENAI_APPS_CHALLENGE= diff --git a/apps/mcp/src/env.d.ts b/apps/mcp/src/env.d.ts index 7ad91b66..5becda73 100644 --- a/apps/mcp/src/env.d.ts +++ b/apps/mcp/src/env.d.ts @@ -12,4 +12,9 @@ interface Env { GITHUB_APP_ID?: string; GITHUB_APP_PRIVATE_KEY?: string; GITHUB_APP_HOME_INSTALLATION_ID?: string; + // Public OpenAI plugin domain-verification token. Served as raw text at + // /.well-known/openai-apps-challenge. Set with `wrangler secret put + // OPENAI_APPS_CHALLENGE --config apps/mcp/wrangler.jsonc` when submitting + // the plugin; unset or blank → 404. + OPENAI_APPS_CHALLENGE?: string; } diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index 6105f81a..5484d905 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -157,6 +157,7 @@ function buildServer(c: Context): McpServer { workspaceName: c.get("workspaceName"), authScopes: c.get("authScopes"), mintingUserId: c.get("mintingUserId") ?? null, + resourceMetadataUrl: `${requestOrigin(c.req.url)}/.well-known/oauth-protected-resource`, }), validator, }); @@ -305,6 +306,17 @@ const app = new Hono() // client derives from `resource` = `/mcp`. .get("/.well-known/oauth-protected-resource", respondProtectedResource) .get("/.well-known/oauth-protected-resource/mcp", respondProtectedResource) + // OpenAI plugin portal domain verification. Must return only the token as + // text/plain — no JSON, no extra bytes. 404 when the secret is unset so a + // draft that hasn't been issued a token yet doesn't serve an empty body. + .get("/.well-known/openai-apps-challenge", (c) => { + const token = c.env.OPENAI_APPS_CHALLENGE?.trim(); + if (!token) throw new NotFoundError(); + return c.text(token, 200, { + "Cache-Control": "public, max-age=60", + "Content-Type": "text/plain; charset=utf-8", + }); + }) // Primary endpoint: the workspace is inferred from the bearer token // (up__…) or, for a JWT-shaped bearer, the OAuth token's // `workspace` claim — so clients only need the URL and the token. diff --git a/apps/mcp/src/robots.ts b/apps/mcp/src/robots.ts index 5da2f744..41440592 100644 --- a/apps/mcp/src/robots.ts +++ b/apps/mcp/src/robots.ts @@ -11,5 +11,6 @@ export const ROBOTS_TXT = `# https://agents.uploads.sh — MCP server only; do n # Public docs and marketing: https://uploads.sh User-agent: * +Allow: /.well-known/openai-apps-challenge Disallow: / `; diff --git a/apps/mcp/src/tools.ts b/apps/mcp/src/tools.ts index edcb2dbb..785b8ac9 100644 --- a/apps/mcp/src/tools.ts +++ b/apps/mcp/src/tools.ts @@ -36,6 +36,15 @@ import { optStringRecord, usage, type McpTool, + insufficientScopeError, + mcpDestroyPublic, + mcpOAuthAny, + mcpOAuthDelete, + mcpOAuthRead, + mcpOAuthWrite, + mcpRead, + mcpWriteInternal, + mcpWritePublic, } from "@buildinternet/uploads/mcp"; import { AppError, NotFoundError } from "@uploads/errors"; import { badKey } from "@uploads/api/files"; @@ -94,6 +103,11 @@ export interface RemoteToolContext { workspace: WorkspaceRecord; workspaceName: string; authScopes: readonly FileScope[]; + /** + * RFC 9728 protected-resource metadata URL for this request's origin. + * Stamped into insufficient-scope tool errors so ChatGPT can re-consent. + */ + resourceMetadataUrl: string; /** * Better Auth user id behind the presented credential (OAuth JWT's `sub`, * or an `up_` token's `minting_user_id`) — same id the REST API's @@ -229,7 +243,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { function requireScope(scope: FileScope): void { // Authorization failure, not a usage error — no (USAGE) suffix in the tool result. - if (!ctx.authScopes.includes(scope)) throw new Error(`forbidden: requires ${scope} scope`); + if (!ctx.authScopes.includes(scope)) { + throw insufficientScopeError(ctx.resourceMetadataUrl, scope); + } } async function requireWriteBudget(): Promise { @@ -317,6 +333,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { return [ { name: "gallery_create", + title: "Create gallery", + annotations: mcpWritePublic, + securitySchemes: mcpOAuthWrite, description: "Create a public ordered media gallery in this workspace. The returned canonical URL is suitable for an agent response, but anyone who knows it can view the gallery and its media.", inputSchema: { @@ -349,6 +368,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "gallery_get", + title: "Get gallery", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Get a workspace-owned gallery, including its ordered media and canonical public URL. Gallery media is public to anyone with the URL.", inputSchema: { @@ -366,6 +388,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "gallery_add", + title: "Add gallery item", + annotations: mcpWritePublic, + securitySchemes: mcpOAuthWrite, description: "Add one existing, publicly served workspace object to a gallery. The tool reads the current version before writing and does not upload or delete the object.", inputSchema: { @@ -433,6 +458,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "gallery_link", + title: "Link gallery", + annotations: mcpWritePublic, + securitySchemes: mcpOAuthWrite, description: "Link a gallery to an external reference. Uses provider-neutral fields; github currently accepts owner/repo#number or a strict GitHub issue/PR URL. No GitHub credentials or API calls are used.", inputSchema: { @@ -470,6 +498,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "gallery_find_by_reference", + title: "Find galleries", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Find galleries in this workspace linked to an external reference. Returns canonical public gallery URLs without contacting the provider.", inputSchema: { @@ -511,6 +542,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "put", + title: "Upload file", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthWrite, description: "Upload base64-encoded content to the workspace and get a public URL plus GitHub-ready embed markdown (the returned `markdown` is ready to paste into a PR or issue). Single file: pass `contentBase64` + `filename` (flat result). Multiple files: pass `files` (uploaded in parallel; returns `uploads` + `failures`, one bad item does not abort the rest). The key defaults to ///-.; pass `key` for an explicit path instead (single-file only). With `pr`/`issue` (+ required `repo`) the key is stable instead (gh/…, always overwrites) and the managed attachments comment is synced by default as uploads-sh[bot] (bot-only on this hosted server, no local gh fallback; body honors the repo's `.uploads.yml` when present) — pass `comment: false` to skip it. With `branch` (+ required `repo`, no pr/issue) stages under gh/…/branch/… for pre-PR capture (CLI attach --branch parity); no comment yet. With `pr` + `branch`, also best-effort promotes that branch's staged files into the PR before the comment sync (CLI attach --pr auto-promote parity). Uploads are public regardless of GitHub repository visibility; explicit predictable keys must contain only non-sensitive media. The stored content type is sniffed from the bytes and restricted to the workspace's allowlist (images plus mp4/webm by default).", inputSchema: { @@ -854,6 +888,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "list", + title: "List files", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "List uploaded objects in the workspace, optionally filtered by key prefix. Paginate with cursor; each item includes its public URL when the workspace has one.", inputSchema: { @@ -882,6 +919,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "delete", + title: "Delete file", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthDelete, description: "Delete an uploaded object in the workspace by key.", inputSchema: { type: "object", @@ -901,6 +941,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "comment", + title: "Sync attachments comment", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthRead, description: "Create or update the managed attachments comment on a GitHub PR or issue, listing everything this workspace has uploaded for it. Refreshes the comment WITHOUT re-uploading — use after deleting media to re-sync (e.g. it will show a neutral empty state once the last attachment is removed). Posts as uploads-sh[bot] via the uploads.sh GitHub App — bot-only on this hosted server, no local gh fallback; body honors the repo's `.uploads.yml` when present (same as the bot path). If the App isn't installed/authorized the decline is returned honestly. Requires repo (owner/name — no git context on this server) and exactly one of pr/issue.", inputSchema: { @@ -939,6 +982,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "promote", + title: "Promote staged attachments", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthWrite, description: "Copy this workspace's branch-staged attachments (gh/…/branch/… keys from put with branch, or CLI attach --branch) into a PR's stable gh/…/pull/… prefix, then optionally refresh the managed attachments comment. Hosted stand-in for `uploads attach --promote` — no git context, so repo, pr, and branch are all required. Does not delete staged originals. Promotion is a pure workspace-data copy (no GitHub API for the copy itself); the comment path is bot-only like the comment tool. Returns { promotion: { promoted, skipped }, comment?, commentError? }.", inputSchema: { @@ -999,6 +1045,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "get_metadata", + title: "Get metadata", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Read an object's queryable custom metadata (D1 key-value pairs, not R2 provenance). Returns `{ metadata }` (empty when none). Object must exist. Same as `uploads meta get`.", inputSchema: { @@ -1017,6 +1066,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "set_metadata", + title: "Set metadata", + annotations: mcpWritePublic, + securitySchemes: mcpOAuthWrite, description: "Merge-set and/or delete an object's queryable custom metadata (D1 key-value pairs, not R2 provenance). `set` wins over `delete` for the same key. " + METADATA_DESCRIPTION + @@ -1054,6 +1106,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "find_files", + title: "Find files", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Find objects whose queryable custom metadata matches ALL of `filters` (ANDed equality) and/or whose key contains `name` (case-insensitive substring). At least one of `filters` or `name` is required. Returns each match's key, public URL, full metadata map, and optional `truncated`. Same as the CLI/local MCP's `find_files` tool.", inputSchema: { @@ -1113,6 +1168,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "list_metadata_keys", + title: "List metadata keys", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "List the distinct queryable metadata keys present in the workspace, with file counts and distinct-value counts. Use this to discover what is filterable before calling find_files — keys are user/agent-defined, not a fixed schema. Same as the CLI's `uploads meta keys`. Pass optional `key` to list that key's values instead (`uploads meta values `).", inputSchema: { @@ -1133,6 +1191,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "repo_link_status", + title: "Check repo binding", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: 'Whether files staged for a repo will auto-attach into that repo\'s PRs from this workspace. Returns a tri-state `binding`: "self" (this repo is bound to this workspace — staged files will auto-attach), "other" (bound to a different workspace — they will not; the owning workspace is deliberately never disclosed), or "none" (unbound).', inputSchema: { @@ -1156,6 +1217,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "usage", + title: "Show usage", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Workspace storage and monthly upload counters (and remaining headroom when budgets are configured).", inputSchema: { @@ -1171,6 +1235,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "reconcile", + title: "Reconcile usage", + annotations: mcpWriteInternal, + securitySchemes: mcpOAuthWrite, description: "Rebuild usage ledger bytes/objects from storage (source of truth). Preserves the monthly upload counter. Requires files:write.", inputSchema: { @@ -1190,6 +1257,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "purge_expired", + title: "Purge expired files", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthDelete, description: "Delete objects older than the workspace retentionDays setting, then reconcile. Skips if retention is unset. Requires files:delete.", inputSchema: { @@ -1213,6 +1283,9 @@ export function createRemoteTools(ctx: RemoteToolContext): McpTool[] { }, { name: "health", + title: "Check health", + annotations: mcpRead, + securitySchemes: mcpOAuthAny, description: "Check uploads.sh MCP server liveness. No scope required.", inputSchema: { type: "object", diff --git a/apps/mcp/test/mcp.test.ts b/apps/mcp/test/mcp.test.ts index f7e9d0c4..f5285fc3 100644 --- a/apps/mcp/test/mcp.test.ts +++ b/apps/mcp/test/mcp.test.ts @@ -104,6 +104,7 @@ async function makeEnv( rateLimitOk?: boolean; record?: Partial; webOrigin?: string; + openaiAppsChallenge?: string; } = {}, ): Promise<{ env: Env; @@ -318,6 +319,9 @@ async function makeEnv( headers: { "content-type": "application/json" }, }), }, + ...(options.openaiAppsChallenge === undefined + ? {} + : { OPENAI_APPS_CHALLENGE: options.openaiAppsChallenge }), } as unknown as Env; return { env, bucket, metadata }; } @@ -402,6 +406,7 @@ async function callTool( isError: boolean; structuredContent?: Record; content: unknown[]; + _meta?: { "mcp/www_authenticate"?: string[] }; }; }; return body.result; @@ -567,6 +572,28 @@ describe("mcp worker", () => { } }); + it("404s the OpenAI apps challenge when no token is configured", async () => { + const { env } = await makeEnv(); + const response = await app.request( + "https://agents.uploads.sh/.well-known/openai-apps-challenge", + { method: "GET" }, + env, + ); + expect(response.status).toBe(404); + }); + + it("serves the OpenAI apps challenge as raw text, not JSON", async () => { + const { env } = await makeEnv({ openaiAppsChallenge: " openai-challenge-token \n" }); + const response = await app.request( + "https://agents.uploads.sh/.well-known/openai-apps-challenge", + { method: "GET" }, + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toMatch(/^text\/plain/); + expect(await response.text()).toBe("openai-challenge-token"); + }); + it("rejects a wrong token with a uniform 401 before any MCP handling", async () => { const { env } = await makeEnv(); const response = await rpc(env, { jsonrpc: "2.0", id: 1, method: "initialize" }, "wrong"); @@ -1024,11 +1051,20 @@ describe("mcp worker", () => { scopes: JSON.stringify(["files:write"]), }, }); - const result = await callTool(env, "get_metadata", { key: "shots/x.png" }, token); + const result = await callTool( + env, + "get_metadata", + { key: "shots/x.png" }, + token, + "https://agents.uploads.sh/test-ws/mcp", + ); expect(result.isError).toBe(true); expect(result.content).toEqual([ { type: "text", text: "forbidden: requires files:read scope" }, ]); + expect(result._meta?.["mcp/www_authenticate"]).toEqual([ + 'Bearer resource_metadata="https://agents.uploads.sh/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="This tool requires the files:read scope"', + ]); }); it("set_metadata merges set + delete and returns the resulting map", async () => { @@ -1437,13 +1473,44 @@ describe("modern-era (2026-07-28) requests", () => { expect(response.status).toBe(200); const body = (await response.json()) as { result: { - tools: { name: string }[]; + tools: { + name: string; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + openWorldHint?: boolean; + }; + }[]; resultType: string; ttlMs: number; cacheScope: string; }; }; expect(body.result.tools.map((tool) => tool.name)).toContain("put"); + const listed = body.result.tools as Array<{ + name: string; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + openWorldHint?: boolean; + }; + }>; + for (const tool of listed) { + expect(tool.annotations).toEqual({ + readOnlyHint: expect.any(Boolean), + destructiveHint: expect.any(Boolean), + openWorldHint: expect.any(Boolean), + }); + } + expect(listed.find((tool) => tool.name === "delete")?.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + expect( + (listed.find((tool) => tool.name === "delete") as { _meta?: { securitySchemes?: unknown } }) + ._meta?.securitySchemes, + ).toEqual([{ type: "oauth2", scopes: ["files:delete"] }]); expect(body.result.resultType).toBe("complete"); expect(body.result.ttlMs).toBe(3_600_000); expect(body.result.cacheScope).toBe("private"); diff --git a/apps/mcp/wrangler.jsonc b/apps/mcp/wrangler.jsonc index c08a7d68..48a99777 100644 --- a/apps/mcp/wrangler.jsonc +++ b/apps/mcp/wrangler.jsonc @@ -18,6 +18,9 @@ // is the only true secret and must be set separately: // wrangler secret put GITHUB_APP_PRIVATE_KEY --config apps/mcp/wrangler.jsonc // Without all three, comment: true degrades to { reason: "app_unconfigured" }. + // OpenAI plugin domain verification (optional until a directory draft + // exists): wrangler secret put OPENAI_APPS_CHALLENGE --config apps/mcp/wrangler.jsonc + // Served as text/plain at /.well-known/openai-apps-challenge. "GITHUB_APP_ID": "4346270", "GITHUB_APP_HOME_INSTALLATION_ID": "147814297", }, diff --git a/apps/web/src/pages/docs/agents.astro b/apps/web/src/pages/docs/agents.astro index b3819634..a205ad86 100644 --- a/apps/web/src/pages/docs/agents.astro +++ b/apps/web/src/pages/docs/agents.astro @@ -75,9 +75,9 @@ import DocsLayout from "../../layouts/DocsLayout.astro"; >

- Codex uses the same repo as a plugin (.codex-plugin/plugin.json): skills plus the - same pre-PR hook. After enabling it, open /hooks once and trust the hook if Codex asks. - Both plugins run + Codex uses the same repo as a plugin (.codex-plugin/plugin.json): skills, the + hosted MCP server, and the same pre-PR hook. After enabling it, open /hooks once and + trust the hook if Codex asks. Both plugins run uploads hook pre-pr-screenshot, so keep the uploads CLI on your PATH and run uploads login once. diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ea98f3df46a32d86ab479688836a011943499bbd GIT binary patch literal 1317 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w@L)Zt|+Cx8@BfKP~PvG3GFb^nEgM4Sym z?<7o90E%-KctjR6FfhLWVaBc1LD>upEc-oO978H@y}i3p@PGmji(}94_cv8764<^Z zt!Iv#ZM)HIL?eieq3f;9y{5JfOfJ!NSl?fzeZ?xMMf)QD6XyRuQX5D^Ua};3$YYJW~Ai zn2`b_P_)jsSiVObnBbWj9N~e7qJR{R!_s@Lzt)5D5mms{z6vd>7z8xr1nY!~j(-)@ zGVXx-InDR148TN|AOH_MvJGD_g@J+2; handler: (args: Record) => Promise; @@ -111,6 +190,13 @@ function wrapHandler(tool: McpTool, apiUrl: string | undefined) { isError: true, }; } + if (err instanceof McpAuthError) { + return { + content: [{ type: "text", text: err.message }], + isError: true, + _meta: { "mcp/www_authenticate": [err.challenge] }, + }; + } return { content: [{ type: "text", text: toolErrorText(err) }], isError: true }; } }; @@ -135,8 +221,11 @@ export function createMcpServer(opts: { server.registerTool( tool.name, { + ...(tool.title ? { title: tool.title } : {}), description: tool.description, inputSchema: fromJsonSchema>(tool.inputSchema, validator), + annotations: tool.annotations, + _meta: { securitySchemes: tool.securitySchemes }, }, wrapHandler(tool, apiUrl), ); diff --git a/packages/uploads/src/mcp/tools.ts b/packages/uploads/src/mcp/tools.ts index affc435d..369cdd8b 100644 --- a/packages/uploads/src/mcp/tools.ts +++ b/packages/uploads/src/mcp/tools.ts @@ -59,7 +59,20 @@ import { usage, type ToolArgs, } from "./args.js"; -import { batchFailureMessage, ToolBatchError, type McpTool } from "./server.js"; +import { + batchFailureMessage, + mcpDestroyPublic, + mcpNoAuth, + mcpOAuthAny, + mcpOAuthDelete, + mcpOAuthRead, + mcpOAuthWrite, + mcpRead, + mcpWriteInternal, + mcpWritePublic, + ToolBatchError, + type McpTool, +} from "./server.js"; import { attachmentFromText, buildReportPayload, @@ -211,6 +224,9 @@ export function createUploadsMcpTools(opts: { return [ { name: "gallery_create", + title: "Create gallery", + annotations: mcpWritePublic, + securitySchemes: mcpOAuthWrite, description: "Create a public ordered media gallery in the workspace. The returned canonical URL is safe to give users, but anyone who knows it can view the gallery and its media.", inputSchema: { @@ -232,6 +248,9 @@ export function createUploadsMcpTools(opts: { }, { name: "gallery_get", + title: "Get gallery", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Get a workspace-owned gallery, including ordered media and its canonical public URL. Gallery media is public to anyone with the URL.", inputSchema: { @@ -250,6 +269,9 @@ export function createUploadsMcpTools(opts: { }, { name: "gallery_add", + title: "Add gallery item", + annotations: mcpWritePublic, + securitySchemes: mcpOAuthWrite, description: "Add one existing, publicly served workspace object to a gallery. Reads the latest gallery version before writing, so the optimistic API version is handled safely. Does not upload or delete the object.", inputSchema: { @@ -279,6 +301,9 @@ export function createUploadsMcpTools(opts: { }, { name: "gallery_link", + title: "Link gallery", + annotations: mcpWritePublic, + securitySchemes: mcpOAuthWrite, description: "Link a gallery to an external reference. References use provider-neutral fields; github currently accepts owner/repo#number or a strict GitHub issue/PR URL. No GitHub credentials or API calls are used.", inputSchema: { @@ -307,6 +332,9 @@ export function createUploadsMcpTools(opts: { }, { name: "gallery_find_by_reference", + title: "Find galleries", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Find workspace galleries linked to an external reference. Returns gallery summaries and canonical public URLs without contacting the provider.", inputSchema: { @@ -335,6 +363,9 @@ export function createUploadsMcpTools(opts: { }, { name: "put", + title: "Upload file", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthWrite, description: "Upload one or more files to uploads.sh and get public URL(s) plus GitHub-ready embed markdown. Single-file: pass `file` or `contentBase64`+`filename` (flat result with `url`/`embedUrl`/`markdown`). Multi-file: pass `files` (paths; parallel; returns `uploads`+`failures`). Prefer `embedUrl` in PR/issue markdown. With `pr`/`issue` keys are stable and `comment` syncs the managed attachments comment. All uploads are public; pr/issue keys are predictable — upload only non-sensitive media.", inputSchema: { @@ -658,6 +689,9 @@ export function createUploadsMcpTools(opts: { }, { name: "screenshot", + title: "Capture screenshot", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthWrite, description: "Capture a URL or a local .html file and host it — a hosted, PR-embeddable image in one call. Backend `local` drives an already-installed Chrome/Chromium (dynamically loaded; unavailable in some runtimes); `remote` renders server-side via the workspace's render endpoint and counts against the monthly upload budget. Default via=auto prefers local when found, else remote. localhost/private-network URLs and .html files are local-only — via=remote (or auto falling back to remote) fails fast instead of a doomed request. Shares the put upload pipeline: optional frame, optimize-by-default, pr/issue attachment + comment, gallery, metadata. Uploads are public.", inputSchema: { @@ -1016,6 +1050,9 @@ export function createUploadsMcpTools(opts: { }, { name: "attach", + title: "Attach to GitHub", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthWrite, description: "Upload one or more files as stable PR/issue attachments (in parallel) and maintain a managed GitHub comment. Returns `uploads` and `failures` (one bad file does not abort the batch). Each success has `url`, `embedUrl`, and `markdown` (prefer embedUrl for GitHub). With no pr/issue, targets the current branch PR. Attachments are public and keys are predictable; upload only non-sensitive media.", inputSchema: { @@ -1121,6 +1158,9 @@ export function createUploadsMcpTools(opts: { }, { name: "list", + title: "List files", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "List uploaded objects in the workspace, filtered by key prefix or by a PR/issue's attachments. Paginate with cursor, or set all to fetch every page.", inputSchema: { @@ -1187,6 +1227,9 @@ export function createUploadsMcpTools(opts: { }, { name: "staged", + title: "List staged files", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Read-only view of what's staged for a git branch (attach --branch / bare put on a non-default branch) and whether it will auto-attach once a PR opens. One list call against the branch staging prefix plus a repo-binding check (files:read only). Returns { repo, branch, files, binding }; binding.state is self/other/none/unknown and binding.autoAttach is true only for self.", inputSchema: { @@ -1213,6 +1256,9 @@ export function createUploadsMcpTools(opts: { }, { name: "delete", + title: "Delete file", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthDelete, description: "Delete an uploaded object by key. Set dryRun to preview without deleting.", inputSchema: { type: "object", @@ -1237,6 +1283,9 @@ export function createUploadsMcpTools(opts: { }, { name: "get_metadata", + title: "Get metadata", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Read an object's queryable custom metadata (D1 key-value pairs, not R2 provenance). Returns `{ metadata }` (empty when none). Object must exist. Same as `uploads meta get`.", inputSchema: { @@ -1256,6 +1305,9 @@ export function createUploadsMcpTools(opts: { }, { name: "set_metadata", + title: "Set metadata", + annotations: mcpWritePublic, + securitySchemes: mcpOAuthWrite, description: "Merge-set and/or delete an object's queryable custom metadata (D1 key-value pairs, not R2 provenance). `set` wins over `delete` for the same key. " + METADATA_DESCRIPTION + @@ -1290,6 +1342,9 @@ export function createUploadsMcpTools(opts: { }, { name: "find_files", + title: "Find files", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Find objects whose queryable custom metadata matches ALL of `filters` (ANDed equality) and/or whose key contains `name` (case-insensitive substring). At least one of `filters` or `name` is required. Returns each match's key, public URL, full metadata map, and optional `truncated`. Same as `uploads find k=v...` / `uploads find --name `.", inputSchema: { @@ -1332,6 +1387,9 @@ export function createUploadsMcpTools(opts: { }, { name: "list_metadata_keys", + title: "List metadata keys", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "List the distinct queryable metadata keys present in the workspace, with file counts and distinct-value counts. Use this to discover what is filterable before calling find_files — keys are user/agent-defined, not a fixed schema. Same as `uploads meta keys`. Pass optional `key` to list that key's values instead (`uploads meta values `).", inputSchema: { @@ -1354,6 +1412,9 @@ export function createUploadsMcpTools(opts: { }, { name: "usage", + title: "Show usage", + annotations: mcpRead, + securitySchemes: mcpOAuthRead, description: "Workspace storage and monthly upload counters (and remaining headroom when budgets are configured). Same as `uploads usage`.", inputSchema: { @@ -1368,6 +1429,9 @@ export function createUploadsMcpTools(opts: { }, { name: "reconcile", + title: "Reconcile usage", + annotations: mcpWriteInternal, + securitySchemes: mcpOAuthWrite, description: "Rebuild usage ledger bytes/objects from storage (source of truth). Preserves the monthly upload counter. Requires files:write. Same as `uploads reconcile`.", inputSchema: { @@ -1382,6 +1446,9 @@ export function createUploadsMcpTools(opts: { }, { name: "purge_expired", + title: "Purge expired files", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthDelete, description: "Delete objects older than the workspace retentionDays setting, then reconcile. Skips if retention is unset. Requires files:delete. Same as `uploads purge-expired`.", inputSchema: { @@ -1396,6 +1463,9 @@ export function createUploadsMcpTools(opts: { }, { name: "comment", + title: "Sync attachments comment", + annotations: mcpDestroyPublic, + securitySchemes: mcpOAuthWrite, description: "Create or update the managed attachments comment on a GitHub PR or issue, listing everything uploaded for it. Posts as uploads-sh[bot] when the GitHub App is installed on the repo; otherwise via local gh auth. Edits its own prior comment in place and never touches other comments.", inputSchema: { @@ -1419,6 +1489,9 @@ export function createUploadsMcpTools(opts: { }, { name: "health", + title: "Check health", + annotations: mcpRead, + securitySchemes: mcpNoAuth, description: "Check uploads.sh API liveness. No auth or arguments required.", inputSchema: { type: "object", properties: {}, additionalProperties: false }, async handler(args) { @@ -1429,6 +1502,9 @@ export function createUploadsMcpTools(opts: { }, { name: "doctor", + title: "Diagnose setup", + annotations: mcpRead, + securitySchemes: mcpOAuthAny, description: "Diagnose the configuration: API health, token auth, and workspace/token alignment. Returns the same report as `uploads doctor --json`, including hints.", inputSchema: { @@ -1443,6 +1519,9 @@ export function createUploadsMcpTools(opts: { }, { name: "report", + title: "Send diagnostic report", + annotations: mcpWriteInternal, + securitySchemes: mcpOAuthWrite, description: "Send an explicit diagnostic report to the uploads team (message + optional text log). " + "Only call this when the user asked to submit feedback, a bug report, or error logs — " + diff --git a/packages/uploads/test/mcp.test.ts b/packages/uploads/test/mcp.test.ts index e2433ef0..90b18e42 100644 --- a/packages/uploads/test/mcp.test.ts +++ b/packages/uploads/test/mcp.test.ts @@ -465,7 +465,41 @@ describe("tools/list", () => { expect(tool.inputSchema.type).toBe("object"); expect(tool.inputSchema.additionalProperties).toBe(false); expect(typeof tool.inputSchema.properties).toBe("object"); + expect(tool.annotations).toEqual({ + readOnlyHint: expect.any(Boolean), + destructiveHint: expect.any(Boolean), + openWorldHint: expect.any(Boolean), + }); + expect(tool._meta.securitySchemes).toEqual([ + expect.objectContaining({ type: expect.stringMatching(/^(oauth2|noauth)$/) }), + ]); } + const byName = Object.fromEntries(tools.map((t) => [t.name, t])); + expect(byName.list.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }); + expect(byName.delete.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + expect(byName.put.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: true, + openWorldHint: true, + }); + expect(byName.reconcile.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, + openWorldHint: false, + }); + expect(byName.delete._meta.securitySchemes).toEqual([ + { type: "oauth2", scopes: ["files:delete"] }, + ]); + expect(byName.list._meta.securitySchemes).toEqual([{ type: "oauth2", scopes: ["files:read"] }]); + expect(byName.health._meta.securitySchemes).toEqual([{ type: "noauth" }]); }); }); diff --git a/plugins/claude/uploads/README.md b/plugins/claude/uploads/README.md index a6f286c7..6203b863 100644 --- a/plugins/claude/uploads/README.md +++ b/plugins/claude/uploads/README.md @@ -1,8 +1,10 @@ # uploads plugin (Claude Code) -Claude Code plugin config for [uploads.sh](https://uploads.sh). The plugin is -declared in [`.claude-plugin/marketplace.json`](../../../.claude-plugin/marketplace.json) -with `source: "./"`, so the whole repo is a one-plugin marketplace. +Claude Code plugin config for [uploads.sh](https://uploads.sh). The plugin +manifest is [`.claude-plugin/plugin.json`](../../../.claude-plugin/plugin.json). +The catalog that lists it is +[`.claude-plugin/marketplace.json`](../../../.claude-plugin/marketplace.json) +(`source: "./"`), so the whole repo is a one-plugin marketplace. ## What it bundles diff --git a/plugins/claude/uploads/commands/attach.md b/plugins/claude/uploads/commands/attach.md index 9c0e4778..280bc5c6 100644 --- a/plugins/claude/uploads/commands/attach.md +++ b/plugins/claude/uploads/commands/attach.md @@ -37,5 +37,5 @@ and embed it in a GitHub PR or issue — or just get back a public URL. (keys, galleries, metadata, `put`/`attach`/`screenshot`), follow the `uploads:uploads-cli` skill. 3. Hosting and lookups can also go through the bundled **uploads MCP server** - (`put`, `list`, `attach`, galleries), which runs the local `uploads mcp` and - reuses your `uploads login` session — see this plugin's README for setup. + (`put`, `list`, galleries) at `https://agents.uploads.sh/mcp` — see this + plugin's README for setup. diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 513f67e2..86d4af77 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -1,10 +1,12 @@ # Codex plugin Manifest: [`.codex-plugin/plugin.json`](../../.codex-plugin/plugin.json). +Listing mark: [`assets/logo.png`](../../assets/logo.png) (the same pixel chevron as the site favicon). -Ships the checked-in skills and the shared pre-PR hook in +Ships the checked-in skills, the hosted MCP server at +`https://agents.uploads.sh/mcp`, and the shared pre-PR hook in [`hooks/hooks.json`](../../hooks/hooks.json) (`uploads hook pre-pr-screenshot`). -Requires the `uploads` CLI on `PATH`. After enabling the plugin, open `/hooks` -once and trust the hook if Codex asks. +The hook requires the `uploads` CLI on `PATH`. After enabling the plugin, open +`/hooks` once and trust the hook if Codex asks. Disable the reminder with `UPLOADS_HOOK_DISABLE=1`. diff --git a/scripts/og/render-og.mjs b/scripts/og/render-og.mjs index 1a84e4f5..1d8d48cc 100644 --- a/scripts/og/render-og.mjs +++ b/scripts/og/render-og.mjs @@ -5,6 +5,7 @@ // apps/web/public/og/home.png 1200x630 (og:image / twitter:image) // apps/web/public/apple-touch-icon.png 180x180 // apps/web/public/favicon-32x32.png 32x32 +// assets/logo.png 512x512 (Codex / OpenAI plugin listing) import { existsSync } from "node:fs"; import { mkdir, readFile } from "node:fs/promises"; import { createRequire } from "node:module"; @@ -73,6 +74,28 @@ try { await page.setContent(iconHtml(px)); await writePng(page, path.join(pub, name)); } + + // Plugin listing mark: same SVG, pixel-scaled onto the brand ground. + // OpenAI requires a square raster ≥ 48px for logo / composerIcon. + const pluginLogo = path.join(root, "assets", "logo.png"); + await mkdir(path.dirname(pluginLogo), { recursive: true }); + const mark = await readFile(path.join(pub, "favicon.svg")); + await sharp({ + create: { width: 512, height: 512, channels: 3, background: "#121214" }, + }) + .composite([ + { + input: await sharp(mark) + .resize(32, 32, { fit: "fill" }) + .resize(480, 480, { kernel: "nearest" }) + .png() + .toBuffer(), + gravity: "center", + }, + ]) + .png({ palette: true, compressionLevel: 9 }) + .toFile(pluginLogo); + console.log(`wrote ${path.relative(root, pluginLogo)}`); } finally { await browser.close(); } From 08d9c61f3fadef64824e04491f5b0f39295c204b Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Fri, 14 Aug 2026 12:00:18 -0400 Subject: [PATCH 2/4] fix: meet OpenAI listing field limits and add portal paste-ins Short description is now 30 characters or fewer, support and category are set, skills and MCP use the root paths the portal validates, and reviewer test cases plus annotation justifications live next to the Codex plugin. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 8 +-- .codex-plugin/plugin.json | 15 +++--- .mcp.json | 6 +++ README.md | 1 + plugins/claude/uploads/README.md | 3 +- plugins/codex/README.md | 8 +-- plugins/codex/submission.md | 93 ++++++++++++++++++++++++++++++++ 8 files changed, 116 insertions(+), 20 deletions(-) create mode 100644 .mcp.json create mode 100644 plugins/codex/submission.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c5aaafeb..9bca4219 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -25,7 +25,7 @@ "./skills/uploads-cli" ], "commands": "./plugins/claude/uploads/commands", - "mcpServers": "./plugins/claude/uploads/.mcp.json", + "mcpServers": "./.mcp.json", "hooks": "./hooks/hooks.json" } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 57eb1734..c33df030 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -11,12 +11,8 @@ "license": "Apache-2.0", "icon": "./assets/logo.png", "keywords": ["uploads", "screenshots", "github", "file-hosting", "images", "mcp"], - "skills": [ - "./skills/github-screenshots", - "./skills/annotate-screenshots", - "./skills/uploads-cli" - ], + "skills": "./skills/", "commands": "./plugins/claude/uploads/commands", - "mcpServers": "./plugins/claude/uploads/.mcp.json", + "mcpServers": "./.mcp.json", "hooks": "./hooks/hooks.json" } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 48fca5bf..cb25cbcc 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -10,21 +10,18 @@ "repository": "https://github.com/buildinternet/uploads", "license": "Apache-2.0", "keywords": ["uploads", "screenshots", "github", "file-hosting", "images", "mcp"], - "skills": [ - "./skills/github-screenshots", - "./skills/annotate-screenshots", - "./skills/uploads-cli" - ], - "mcpServers": "./plugins/claude/uploads/.mcp.json", + "skills": "./skills/", + "mcpServers": "./.mcp.json", "hooks": "./hooks/hooks.json", "interface": { "displayName": "uploads.sh", - "shortDescription": "Host files and attach them to GitHub PRs and issues", + "shortDescription": "Host files for GitHub PRs", "longDescription": "Host screenshots, GIFs, recordings, and files on uploads.sh and embed them in GitHub PRs and issues. Bundles the github-screenshots, annotate-screenshots, and uploads-cli skills, the hosted MCP server at agents.uploads.sh, and a pre-PR screenshot reminder hook.", "developerName": "Build Internet", - "category": "Productivity", - "capabilities": ["Read", "Write"], + "category": "Developer Tools", + "capabilities": ["Host files on a public CDN", "Attach media to GitHub PRs"], "websiteURL": "https://uploads.sh", + "supportURL": "https://github.com/buildinternet/uploads/issues", "privacyPolicyURL": "https://uploads.sh/privacy", "termsOfServiceURL": "https://uploads.sh/terms", "defaultPrompt": [ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..11937023 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,6 @@ +{ + "uploads": { + "type": "http", + "url": "https://agents.uploads.sh/mcp" + } +} diff --git a/README.md b/README.md index a95dadb4..7d92be2f 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ REST routes are in [docs/api.md](docs/api.md). | `plugins/claude/` | Claude Code plugin config (skills path, MCP, commands) | | `.claude-plugin/` | Claude marketplace catalog + plugin manifest | | `.codex-plugin/` | Codex plugin manifest — skills, hosted MCP, and shared hook | +| `.mcp.json` | Hosted MCP server for both plugins (`https://agents.uploads.sh/mcp`) | | `assets/logo.png` | Pixel chevron mark for the Codex / OpenAI plugin listing | The workers and web app are separate deployables. All storage access goes diff --git a/plugins/claude/uploads/README.md b/plugins/claude/uploads/README.md index 6203b863..bddf26bf 100644 --- a/plugins/claude/uploads/README.md +++ b/plugins/claude/uploads/README.md @@ -4,7 +4,8 @@ Claude Code plugin config for [uploads.sh](https://uploads.sh). The plugin manifest is [`.claude-plugin/plugin.json`](../../../.claude-plugin/plugin.json). The catalog that lists it is [`.claude-plugin/marketplace.json`](../../../.claude-plugin/marketplace.json) -(`source: "./"`), so the whole repo is a one-plugin marketplace. +(`source: "./"`), so the whole repo is a one-plugin marketplace. The hosted MCP +server is declared in [`.mcp.json`](../../../.mcp.json). ## What it bundles diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 86d4af77..b0a9692c 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -3,9 +3,11 @@ Manifest: [`.codex-plugin/plugin.json`](../../.codex-plugin/plugin.json). Listing mark: [`assets/logo.png`](../../assets/logo.png) (the same pixel chevron as the site favicon). -Ships the checked-in skills, the hosted MCP server at -`https://agents.uploads.sh/mcp`, and the shared pre-PR hook in -[`hooks/hooks.json`](../../hooks/hooks.json) (`uploads hook pre-pr-screenshot`). +Ships the checked-in skills, the hosted MCP server in +[`.mcp.json`](../../.mcp.json) (`https://agents.uploads.sh/mcp`), and the shared +pre-PR hook in [`hooks/hooks.json`](../../hooks/hooks.json) +(`uploads hook pre-pr-screenshot`). Portal paste-ins (test cases, annotation +justifications) live in [submission.md](submission.md). The hook requires the `uploads` CLI on `PATH`. After enabling the plugin, open `/hooks` once and trust the hook if Codex asks. diff --git a/plugins/codex/submission.md b/plugins/codex/submission.md new file mode 100644 index 00000000..c95fdaec --- /dev/null +++ b/plugins/codex/submission.md @@ -0,0 +1,93 @@ +# OpenAI directory paste-ins + +Copy these into the plugin submission portal after identity verification. +They are not loaded at runtime. + +## Annotation justifications + +Every hosted tool advertises `readOnlyHint`, `openWorldHint`, and +`destructiveHint`. Use the matching row when the portal asks why. + +| Tools | readOnly | openWorld | destructive | Why | +| --------------------------------------------------------------------------------------------------------------------------------------------- | -------- | --------- | ----------- | ------------------------------------------------------------------------------------------------------- | +| `list`, `gallery_get`, `gallery_find_by_reference`, `get_metadata`, `find_files`, `list_metadata_keys`, `repo_link_status`, `usage`, `health` | true | false | false | Fetch or compute only. No writes. | +| `gallery_create`, `gallery_add`, `gallery_link`, `set_metadata` | false | true | false | Create or update a public gallery or public `/f/` metadata. Do not delete objects. | +| `put`, `promote` | false | true | true | Upload or copy to a public URL. `pr`/`issue` keys overwrite in place. May post a public GitHub comment. | +| `comment` | false | true | true | Overwrites the managed attachments comment on a public GitHub PR or issue. | +| `delete`, `purge_expired` | false | true | true | Permanently remove public objects. | +| `reconcile` | false | false | false | Rebuilds the workspace usage ledger only. No public objects change. | + +Stdio-only tools follow the same rules: `screenshot` and `attach` match `put`; +`staged` and `doctor` match the read-only row; `report` is an internal write +(`readOnly` false, `openWorld` false, `destructive` false). + +## Positive test cases + +Reviewer account: a GitHub-linked uploads.sh user with a workspace, the +uploads GitHub App installed on `buildinternet/uploads` (or a fixture repo +the reviewer can write), and no MFA step after the first OAuth consent. + +### 1. Host a file and get a public URL + +- **Prompt:** Give me a public URL for this PNG. (attach a small screenshot) +- **Expected tools:** `put` with `filename` + `contentBase64`. +- **Expected result:** `{ url, embedUrl, markdown, key, size }` and a 200 + fetch of `url`. + +### 2. Attach to an existing pull request + +- **Prompt:** Attach this screenshot to pull request 668 in buildinternet/uploads. +- **Expected tools:** `put` with `repo`, `pr`, and the file; optional + `comment`. +- **Expected result:** a stable `gh/…/pull/668/…` key, `embedUrl` for GitHub + markdown, and a managed attachments comment on the PR (or an honest + `comment` decline such as `not_installed`). + +### 3. Stage a before/after before a PR exists + +- **Prompt:** Stage a before/after of the settings page for branch + `feat/openai-plugin-listing` on buildinternet/uploads. +- **Expected tools:** two `put` calls with `repo`, `branch`, `state` before + then after, and `metadata.path=/settings` (or equivalent). +- **Expected result:** keys under `gh/…/branch/feat-openai-plugin-listing/…` + and `gh.status=staged`. + +### 4. List what is staged + +- **Prompt:** What files are staged for feat/openai-plugin-listing on + buildinternet/uploads? +- **Expected tools:** `list` with that branch prefix, or `find_files` with + `gh.branch`, plus `repo_link_status`. +- **Expected result:** the staged objects from case 3 and a `binding` of + `self`, `other`, or `none`. + +### 5. Refresh the attachments comment without uploading + +- **Prompt:** Refresh the attachments comment on pull request 668 in + buildinternet/uploads. Do not upload anything. +- **Expected tools:** `comment` with `repo` and `pr`. +- **Expected result:** the managed comment updated in place, or an honest + decline (`not_installed`, `not_authorized`, `forbidden`). The call is not a + thrown tool error on those declines. + +## Negative test cases + +### 1. Upload a secret + +- **Prompt:** Upload this screenshot of my `.env` with the API keys visible. +- **Expected behavior:** refuse. Point at redaction (`annotate` / a solid + redact) or ask the user to crop first. Do not call `put`. + +### 2. Delete another workspace's file + +- **Prompt:** Delete `gh/some-other-org/private-app/pull/1/secret.png`. +- **Expected behavior:** do not delete. Either refuse, or `delete` fails + because the key is not in this workspace / the token lacks `files:delete`. + +### 3. Attach to a repo the token cannot claim + +- **Prompt:** Attach this image to pull request 1 in a repo this workspace + has never used and does not have push access to. +- **Expected behavior:** the upload may succeed; the managed comment returns + `not_authorized` or `not_installed` instead of posting as the user. Do not + invent a comment URL. From 1ad0d765428808bd51abb1b708860ad33660ec87 Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Fri, 14 Aug 2026 12:21:49 -0400 Subject: [PATCH 3/4] docs(skills): route ChatGPT to hosted MCP instead of the CLI Put the MCP vs CLI decision at the top of github-screenshots and uploads-cli so hosts without a shell do not try to run uploads attach. --- skills/github-screenshots/SKILL.md | 49 ++++++++++++++++++------------ skills/uploads-cli/SKILL.md | 34 ++++++++++++++++----- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/skills/github-screenshots/SKILL.md b/skills/github-screenshots/SKILL.md index 84e881ce..87ed6701 100644 --- a/skills/github-screenshots/SKILL.md +++ b/skills/github-screenshots/SKILL.md @@ -27,12 +27,32 @@ description: >- GitHub's native image hosting (`github.com/user-attachments/…`) only works from an authenticated browser session — there is no `gh` CLI or REST endpoint for it. Any image URL in a PR/issue body written with `gh … --body-file` must -already point at something publicly hosted. The **`uploads` CLI** provides -that: it hosts the file on uploads.sh and returns a stable public URL plus -ready-to-paste markdown. +already point at something publicly hosted. The **`uploads` CLI** and the +hosted MCP at `https://agents.uploads.sh/mcp` both host the file on uploads.sh +and return a stable public URL plus ready-to-paste markdown. + +## Which surface + +Pick one transport and stay on it. This skill is the workflow. The +**uploads-cli** skill owns flags and MCP tool contracts. + +| You have | Use | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No shell (ChatGPT, or any host without a checkout) | Hosted MCP `put` with `filename` + `contentBase64` + `repo` + (`pr` or `branch`). Embed the returned `markdown` / `embedUrl`. Never imply you can run `uploads attach ./shot.png`. | +| A checkout and the `uploads` binary | The CLI examples below. Git can fill `repo` / `branch`. | +| A `localhost` page or a selector annotate | CLI only (`uploads screenshot --via local`). Remote render cannot reach your machine. | +| Neither MCP nor the CLI | Stop and say so. Do not treat `npm install -g` as the ChatGPT path. | + +On the hosted MCP there is no `attach` tool and no git defaults. Stage with +`put` + `branch` + `repo`. Once the PR exists, `promote` with `repo` + `pr` + +`branch`, or `put` with `pr` + `repo` (optional `branch` also promotes). The +managed comment is bot-only on that server. ## Step 1 — Capture the visual +Skip this step if the visual is already in context (a ChatGPT attachment, a +file the host already holds). Go straight to hosted MCP `put`. + **Prefer `uploads screenshot `** — it captures **and** hosts in one step (drives a local Chrome, or falls back to a server-side render), so you skip a separate host call. It takes `--viewport WxH@Nx`, `--wait`, `--selector`, @@ -121,16 +141,6 @@ The zero-setup fallback that works regardless of binding history: once the PR exists, run `uploads attach --promote` (or any targeted `uploads attach` against that PR) to promote and post explicitly. -**No local filesystem?** An agent driving the hosted MCP -(`agents.uploads.sh/mcp`, no CLI, no git checkout) can still run the same loop -with explicit `repo`/`branch`/`pr` (no git defaults on the server): - -- Stage as you go: `put` with `branch` + `repo` + base64 content -- Once the PR exists: `promote` with `repo` + `pr` + `branch`, or `put` with - `pr` + `repo` (and optional `branch` to also promote staged files) -- Managed comment is bot-only on this server — see the **uploads-cli** skill - for contracts and honest decline reasons - **Pass `--state before`/`--state after` and `--meta path=/route` as a habit — both, every time.** Before/after is the whole point of most PR screenshots, and it's the one thing no tool can infer from the image; `path` is the other @@ -258,12 +268,13 @@ handful of milestones reads better than a dumped folder. ## Setup and escalation -- CLI missing? `npm install --global @buildinternet/uploads` -- Not authenticated? `uploads login` (one-time, opens a browser), then - `uploads doctor` to verify. -- Everything deeper — flags, key layouts, metadata and search, galleries, - config defaults, output formats, exit codes — lives in the **uploads-cli** - skill and `uploads --help`. +- No shell? Use the hosted MCP. Do not install the CLI. +- CLI missing on a machine with a shell? `npm install --global @buildinternet/uploads` +- Not authenticated on the CLI? `uploads login` (one-time, opens a browser), + then `uploads doctor` to verify. Hosted MCP uses OAuth on first tool call. +- Everything deeper — flags, key layouts, MCP tool contracts, metadata and + search, galleries, config defaults, output formats, exit codes — lives in + the **uploads-cli** skill and `uploads --help`. ## Cautions diff --git a/skills/uploads-cli/SKILL.md b/skills/uploads-cli/SKILL.md index d1784048..c8d105df 100644 --- a/skills/uploads-cli/SKILL.md +++ b/skills/uploads-cli/SKILL.md @@ -25,11 +25,29 @@ through an authenticated **browser session** — there is no `gh` CLI or REST en for it. So any image URL you put in a PR/issue body written with `gh … --body-file` must already point at something publicly hosted. -This skill solves that with the **`uploads` CLI**: it PUTs a local file to the -uploads.sh API, which returns a stable public URL you can drop straight into -markdown. No browser, no repo bloat, no signing by hand. For PRs and issues it can -also create and maintain a single "attachments" comment for you via your local -`gh` auth. +This skill covers both transports: the **`uploads` CLI** (local files, git, +localhost) and the hosted MCP at `https://agents.uploads.sh/mcp` (bytes you +already have, no checkout). Both PUT to the uploads.sh API and return a stable +public URL plus ready-to-paste markdown. For PRs and issues the managed +attachments comment is available on both — CLI via local `gh` as a fallback, +hosted MCP bot-only. + +### MCP vs CLI + +Same product, two transports. Skills do not install a binary. + +| Need | Use | Why | +| ----------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Bytes already in context (ChatGPT attachment, base64) | Hosted MCP `put` | `files: [{ filename, contentBase64 }]`. Pass `repo` + (`pr` \| `branch`). No git inference. | +| List, find, metadata, comment, promote | Either | Hosted: `list`, `find_files`, `get_metadata` / `set_metadata`, `comment`, `promote`. CLI: `uploads list` / `find` / `meta` / `comment` / `attach --promote`. | +| Local path or current-branch attach | CLI | Hosted server has no filesystem and no `attach` tool. Use `put` instead. | +| `localhost` / private-network screenshot | CLI `uploads screenshot --via local` | Remote render cannot reach your machine. | +| Selector annotate on a live page | CLI `uploads screenshot --annotate --via local` | Remote backend rejects selector-bearing specs. | +| Neither transport | Stop | Do not treat `npm install -g` as the ChatGPT path. OAuth on `https://agents.uploads.sh/mcp` is the published remote path. | + +CLI examples in the rest of this skill assume a checkout and the `uploads` +binary. Hosted tool contracts live under **Notes and cautions** (the MCP +bullet) below. For the common case, use `uploads attach `. It infers the current branch's PR, uploads every file under stable attachment keys (in parallel), and maintains @@ -94,8 +112,8 @@ the two surfaces never drift. Local stdio MCP mirrors this as the `staged` tool (`branch`/`repo` args, same `{ repo, branch, files, binding }` shape). The hosted MCP has no dedicated `staged` tool (no git defaults) — list/find_files recipes and -hosted `put`/`promote` with explicit `repo`/`branch` are under "Hosted MCP" -below. +hosted `put`/`promote` with explicit `repo`/`branch` are under **Notes and +cautions** (the MCP bullet) below. Getting those files into the PR's attachments comment needs no extra step once a PR exists for that branch: @@ -162,6 +180,8 @@ comment already prefer `embedUrl`. Override with `UPLOADS_EMBED_PUBLIC_BASE_URL` ## Prerequisites +- **No shell / ChatGPT?** Skip this section. Use the hosted MCP + (`https://agents.uploads.sh/mcp`) and the table above. Do not install the CLI. - **Node.js ≥ 22.** - **The CLI.** Install globally for repeated agent use, or run it once with `npx`: ```bash From 939d4f70661c5218aa8f65b99f5a0b9d8476181a Mon Sep 17 00:00:00 2001 From: Zach Dunn Date: Fri, 14 Aug 2026 12:29:50 -0400 Subject: [PATCH 4/4] chore(lint): turn off Express async-handler rule for Hono oxc/no-async-endpoint-handlers assumes Express 4, which does not await route handlers. This repo is Hono on Workers, so the diagnostic is a false positive on MCP auth middleware. --- .oxlintrc.json | 1 + AGENTS.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.oxlintrc.json b/.oxlintrc.json index ec0d1e29..eaeac8a1 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -23,6 +23,7 @@ "unicorn/prefer-set-has": "off", "unicorn/no-array-sort": "off", "unicorn/no-array-reverse": "off", + "oxc/no-async-endpoint-handlers": "off", "typescript/await-thenable": "error", "typescript/consistent-return": "off", diff --git a/AGENTS.md b/AGENTS.md index 1f91bd7b..ff186549 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,10 @@ records; any future global secrets go through `wrangler secret put` (prod) or `--fix` flags are global, so there is no way to keep a rule's diagnostic while suppressing only its fix. If you re-enable any of them, re-check that `pnpm lint:fix` still leaves a clean tree. +- `oxc/no-async-endpoint-handlers` is off because it is an Express 4 rule + (unhandled rejections from `async` route handlers). This repo is Hono on + Workers; Hono awaits handlers and `onError` catches throws. The diagnostic + is a false positive on every `app.post(..., async (c) => …)` middleware. - A Husky pre-commit hook runs `pnpm types` then `lint-staged` (oxlint + oxfmt on staged files; Prettier for `*.astro` — oxfmt has no Astro parser); it's installed via the `prepare` script on `pnpm install`.