Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ node_modules/
dist/
*.tsbuildinfo

# MCP bundle (built by scripts/build-mcpb.sh)
*.mcpb
.mcpb-build/

# Logs
*.log
npm-debug.log*
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
36 changes: 36 additions & 0 deletions scripts/build-mcpb.sh
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -12,7 +12,7 @@
{
"registryType": "npm",
"identifier": "@pasteapp/mcp",
"version": "0.1.2",
"version": "0.1.3",
"transport": {
"type": "stdio"
}
Expand Down
34 changes: 31 additions & 3 deletions src/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,46 @@ function parsePort(raw: string): number | null {
export interface DiscoverOptions {
paths?: string[];
read?: (plistPath: string, key: string) => Promise<string | null>;
probe?: (url: URL) => Promise<boolean>;
}

// 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<boolean> {
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<URL | null> {
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;
}
8 changes: 4 additions & 4 deletions src/oauth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
60 changes: 50 additions & 10 deletions test/discover.test.ts
Original file line number Diff line number Diff line change
@@ -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:<port>/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');
});
});
19 changes: 19 additions & 0 deletions test/oauth/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading