diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 623906e..b8f4220 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,7 @@ on: types: [published] permissions: - contents: read + contents: write # attach the built .mcpb bundle to the release id-token: write # required for npm provenance via OIDC trusted publishing jobs: @@ -32,14 +32,25 @@ jobs: # made with MCP_REGISTRY_PRIVATE_KEY. The well-known file must be live # before this runs. The npm step above must have published this version, # which carries the matching `mcpName` for package validation. - - name: Sync server.json version to package.json + - name: Sync server.json + manifest.json version to package.json run: | VERSION="$(jq -r .version package.json)" jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp mv server.tmp server.json + jq --arg v "$VERSION" '.version = $v' manifest.json > manifest.tmp + mv manifest.tmp manifest.json - name: Install mcp-publisher run: curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher - name: Authenticate to the MCP Registry (pasteapp.io domain auth) run: ./mcp-publisher login http --domain pasteapp.io --private-key "${{ secrets.MCP_REGISTRY_PRIVATE_KEY }}" - name: Publish server to the MCP Registry run: ./mcp-publisher publish + + # Build the Claude Desktop bundle (.mcpb) and attach it to the GitHub + # release so the directory listing always tracks the published version. + - name: Build .mcpb bundle + run: bash scripts/build-mcpb.sh + - name: Attach .mcpb to the release + run: gh release upload "${{ github.event.release.tag_name }}" paste.mcpb --clobber + env: + GH_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index c8a726c..8d74260 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ node_modules/ dist/ *.tsbuildinfo +# MCP bundle (built by scripts/build-mcpb.sh) +*.mcpb +.mcpb-build/ + # Logs *.log npm-debug.log* diff --git a/README.md b/README.md index e87ae2f..a5f886c 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,11 @@ You can also connect any app right inside Paste. Open **Settings → MCP & AI To ![Connecting an AI tool from Paste's MCP & AI Tools settings](assets/connect-ai-tools.png) -## Privacy +## Privacy Policy -Paste MCP runs locally on your Mac. Your clipboard items only go to the AI apps you approve. You can revoke access anytime. +Paste MCP runs locally on your Mac — it bridges your AI app to Paste's on-device server and sends nothing to Paste's own servers. Your clipboard items only reach the AI apps you approve, and you can revoke access anytime in **Settings → MCP & AI Tools**. + +Full policy — what data is processed, how it's stored, sharing, retention, and contact: **https://pasteapp.io/privacy**. ## Troubleshooting diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..20f6be7 Binary files /dev/null and b/assets/icon.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..cf582af --- /dev/null +++ b/manifest.json @@ -0,0 +1,44 @@ +{ + "manifest_version": "0.3", + "name": "paste", + "display_name": "Paste", + "version": "0.1.3", + "description": "Give Claude access to your Mac clipboard history and pinboards from Paste.", + "long_description": "Paste connects your Mac's clipboard history and pinboards to Claude through MCP. Ask Claude to search what you copied, pull a saved item into context, save new items, and organize pinboards — all running locally on your Mac. Requires the Paste app with MCP enabled (Settings → MCP & AI Tools); the first time Claude uses Paste you approve access.", + "author": { + "name": "Paste Team ApS", + "url": "https://pasteapp.io" + }, + "homepage": "https://pasteapp.io/mcp", + "documentation": "https://pasteapp.io/help/paste-mcp", + "support": "https://github.com/pasteapp/paste-mcp/issues", + "icon": "icon.png", + "server": { + "type": "node", + "entry_point": "server/index.js", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/server/index.js"] + } + }, + "tools": [ + { "name": "search", "description": "Search your Paste clipboard history and pinboards." }, + { "name": "read_item", "description": "Read the full content of a saved item by id." }, + { "name": "create_item", "description": "Save a new item to Paste." }, + { "name": "update_item", "description": "Update the content of a saved item." }, + { "name": "delete_item", "description": "Delete a saved item." }, + { "name": "list_pinboards", "description": "List your pinboards." }, + { "name": "create_pinboard", "description": "Create a new pinboard." }, + { "name": "rename_pinboard", "description": "Rename a pinboard." }, + { "name": "delete_pinboard", "description": "Delete a pinboard." }, + { "name": "add_item_to_pinboard", "description": "Add an item to a pinboard." }, + { "name": "remove_item_from_pinboard", "description": "Remove an item from a pinboard." } + ], + "privacy_policies": ["https://pasteapp.io/privacy"], + "compatibility": { + "platforms": ["darwin"], + "runtimes": { + "node": ">=18.0.0" + } + } +} diff --git a/package.json b/package.json index 7e86ede..b2da48e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasteapp/mcp", - "version": "0.1.2", + "version": "0.1.3", "mcpName": "io.pasteapp/paste", "description": "Local MCP server bridge for Paste — give Claude Desktop, Claude Code, Cursor, Codex, and other AI tools access to your Mac clipboard history and pinboards.", "keywords": [ diff --git a/scripts/build-mcpb.sh b/scripts/build-mcpb.sh new file mode 100755 index 0000000..4aa8a5d --- /dev/null +++ b/scripts/build-mcpb.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Build the Paste MCP Bundle (.mcpb) for the Claude Desktop extension directory. +# Self-contained: bundles the built bridge + its production deps so it runs on +# Claude Desktop's built-in Node — the user never installs npm or our package. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +stage="$root/.mcpb-build" +out="$root/paste.mcpb" + +rm -rf "$stage" "$out" +mkdir -p "$stage/server" + +# 1. Build the TypeScript bridge (needs devDeps). +npm ci +npm run build + +# 2. Stage the compiled server. +cp -R "$root/dist/." "$stage/server/" + +# 3. Production-only dependency tree, bundled at the root so Node resolves +# `@modelcontextprotocol/sdk` from server/index.js by walking up. +npm ci --omit=dev +cp -R "$root/node_modules" "$stage/node_modules" +# Restore the full tree so the working copy stays dev-ready. +npm ci + +# 4. Manifest + icon at the bundle root. +cp "$root/manifest.json" "$stage/manifest.json" +cp "$root/assets/icon.png" "$stage/icon.png" + +# 5. Validate, then pack. +npx -y @anthropic-ai/mcpb validate "$stage/manifest.json" +npx -y @anthropic-ai/mcpb pack "$stage" "$out" + +echo "Built $out" diff --git a/server.json b/server.json index 5a4d57b..bcb686c 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.pasteapp/paste", "description": "Give AI tools access to your Mac clipboard history and pinboards from Paste. Runs locally.", - "version": "0.1.2", + "version": "0.1.3", "websiteUrl": "https://pasteapp.io/mcp", "repository": { "url": "https://github.com/pasteapp/paste-mcp", @@ -12,7 +12,7 @@ { "registryType": "npm", "identifier": "@pasteapp/mcp", - "version": "0.1.2", + "version": "0.1.3", "transport": { "type": "stdio" } diff --git a/src/discover.ts b/src/discover.ts index 7bfb835..0e2414c 100644 --- a/src/discover.ts +++ b/src/discover.ts @@ -45,18 +45,46 @@ function parsePort(raw: string): number | null { export interface DiscoverOptions { paths?: string[]; read?: (plistPath: string, key: string) => Promise; + probe?: (url: URL) => Promise; +} + +// Paste's default MCP port — fallback when the plist read is blocked (the App +// Store build keeps the port in its sandbox container, unreadable without FDA). +const DEFAULT_PORT = 39725; + +// A live endpoint answers (even a 401); only a refused/timed-out connection means nothing's listening. +async function probeURL(url: URL): Promise { + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"jsonrpc":"2.0","id":0,"method":"ping"}', + signal: AbortSignal.timeout(800), + }); + return response.status > 0; + } catch { + return false; + } } export async function discoverServerURL(opts: DiscoverOptions = {}): Promise { const paths = opts.paths ?? plistPaths(); const read = opts.read ?? readDefault; - // Read both candidates in parallel — startup latency matters because each - // bridge spawn waits on this before serving anything. + const probe = opts.probe ?? probeURL; + + // Plist port first (honors a custom port), then the default as a fallback. + const ports: number[] = []; const results = await Promise.allSettled(paths.map((path) => read(path, PORT_KEY))); for (const result of results) { if (result.status !== 'fulfilled' || result.value === null) continue; const port = parsePort(result.value); - if (port !== null) return new URL(`http://127.0.0.1:${port}/mcp`); + if (port !== null && !ports.includes(port)) ports.push(port); + } + if (!ports.includes(DEFAULT_PORT)) ports.push(DEFAULT_PORT); + + for (const port of ports) { + const url = new URL(`http://127.0.0.1:${port}/mcp`); + if (await probe(url)) return url; } return null; } diff --git a/src/oauth/client.ts b/src/oauth/client.ts index de8ffff..940b9b4 100644 --- a/src/oauth/client.ts +++ b/src/oauth/client.ts @@ -56,10 +56,10 @@ export class OAuthClient { // on 401 if the token actually went stale. return cached.tokens.access_token; } - // Different server URL (port changed, channel switched) → drop the cache. - if (cached && cached.serverURL !== this.serverURL.toString()) { - await this.store.clear(); - } + // Drop stale state before a fresh flow: a client registration from an + // aborted attempt is bound to its old callback port and would fail this + // flow's redirect_uri (`invalid_redirect_uri`). + await this.store.clear(); const callback = await this.startCallbackServerImpl(); const provider = new BridgeProvider({ serverURL: this.serverURL.toString(), diff --git a/test/discover.test.ts b/test/discover.test.ts index db5d23b..57faef9 100644 --- a/test/discover.test.ts +++ b/test/discover.test.ts @@ -1,44 +1,84 @@ import { describe, it, expect } from 'vitest'; import { discoverServerURL } from '../src/discover.js'; +// Treat only the listed ports as "listening" so tests never touch the network. +const liveOn = + (...ports: number[]) => + async (url: URL) => + ports.includes(Number(url.port)); + describe('discoverServerURL', () => { - it('returns http://127.0.0.1:/mcp from the first path that yields a port', async () => { + it('returns the plist port when it is live', async () => { const url = await discoverServerURL({ paths: ['/container.plist', '/preferences.plist'], read: async (path) => (path === '/container.plist' ? '54321' : null), + probe: liveOn(54321), }); expect(url?.toString()).toBe('http://127.0.0.1:54321/mcp'); }); - it('falls through to the next path when the first is absent', async () => { + it('falls through to the next plist path for the port', async () => { const url = await discoverServerURL({ paths: ['/container.plist', '/preferences.plist'], - read: async (path) => (path === '/preferences.plist' ? '39725' : null), + read: async (path) => (path === '/preferences.plist' ? '50000' : null), + probe: liveOn(50000), }); - expect(url?.toString()).toBe('http://127.0.0.1:39725/mcp'); + expect(url?.toString()).toBe('http://127.0.0.1:50000/mcp'); }); - it('returns null when no path yields a port', async () => { + it('falls back to the default port when the plist read is blocked', async () => { + // The case that matters for Claude Desktop: it can't read Paste's App Store + // sandbox container, so the plist read yields nothing. const url = await discoverServerURL({ - paths: ['/a', '/b'], + paths: ['/blocked'], read: async () => null, + probe: liveOn(39725), + }); + expect(url?.toString()).toBe('http://127.0.0.1:39725/mcp'); + }); + + it('prefers a live plist port over the default', async () => { + const url = await discoverServerURL({ + paths: ['/a'], + read: async () => '54321', + probe: liveOn(54321, 39725), + }); + expect(url?.toString()).toBe('http://127.0.0.1:54321/mcp'); + }); + + it('falls back to the default when the plist port is stale (not listening)', async () => { + const url = await discoverServerURL({ + paths: ['/a'], + read: async () => '54321', + probe: liveOn(39725), + }); + expect(url?.toString()).toBe('http://127.0.0.1:39725/mcp'); + }); + + it('returns null when nothing is listening', async () => { + const url = await discoverServerURL({ + paths: ['/a'], + read: async () => '54321', + probe: async () => false, }); expect(url).toBeNull(); }); - it('rejects out-of-range ports', async () => { + it('ignores out-of-range plist ports but still tries the default', async () => { const url = await discoverServerURL({ paths: ['/a'], read: async () => '99999', + probe: liveOn(39725), }); - expect(url).toBeNull(); + expect(url?.toString()).toBe('http://127.0.0.1:39725/mcp'); }); - it('rejects non-numeric port values', async () => { + it('ignores non-numeric plist values', async () => { const url = await discoverServerURL({ paths: ['/a'], read: async () => 'not-a-port', + probe: liveOn(39725), }); - expect(url).toBeNull(); + expect(url?.toString()).toBe('http://127.0.0.1:39725/mcp'); }); }); diff --git a/test/oauth/client.test.ts b/test/oauth/client.test.ts index 36febc4..153b1b3 100644 --- a/test/oauth/client.test.ts +++ b/test/oauth/client.test.ts @@ -215,6 +215,25 @@ describe('OAuthClient', () => { expect((await store.load())?.serverURL).toBe(`${mock.url}/mcp`); }); + it('re-registers when a prior attempt left a client registration but no token (cancel → retry)', async () => { + // An aborted/cancelled flow persists a client registration bound to its old + // callback port, but no token. Reusing it would fail the new redirect_uri, + // so the next attempt must drop it and register afresh. + await store.save({ + serverURL: `${mock.url}/mcp`, + clientInformation: { client_id: 'stale', redirect_uris: ['http://127.0.0.1:1/cb'] }, + }); + + const client = new OAuthClient(new URL(`${mock.url}/mcp`), store, { + openBrowser: simulatedBrowser(), + }); + const token = await client.accessToken(); + + expect(token).toBe('tok-for-mock-code-1'); + expect(mock.registerCalls).toBe(1); // re-registered, did not reuse 'stale' + expect((await store.load())?.clientInformation?.client_id).toBe('mock-client-1'); + }); + }); describe('assertLoopbackHTTPURL', () => {