mcpship 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Freeapp SRL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # mcpship
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Check a remote MCP server against the Claude connector directory rules, then publish it: npm, the official MCP Registry, a Claude Code plugin repo and the Claude connector directory. One step at a time, for people and for agents.
4
+
5
+ ## Quick start
6
+
7
+ npx mcpship init https://example.com/mcp # writes publish.json; fill in the fields
8
+ MCPSHIP_TOKEN=<access token or API key> npx mcpship check
9
+ npx mcpship next # the next step, with the exact command
10
+
11
+ ## Commands
12
+
13
+ | Command | What it does |
14
+ | --- | --- |
15
+ | `init <url>` | Writes a publish.json template in the current folder |
16
+ | `check [url]` | Tests sign-in discovery, tools and listing; exit code 1 if a rule fails |
17
+ | `packet` | Writes docs/directory/submission.md: the text for each directory form step |
18
+ | `next` | The first unfinished step, with the command or click |
19
+ | `status` | Live state of npm, the MCP Registry, the plugin repo and the directory |
20
+ | `done directory` | Records that you submitted the directory form |
21
+ | `mcp` | Runs as a local MCP server over stdio |
22
+
23
+ Add `--json` for machine-readable output and `--dir <path>` to point at another folder.
24
+
25
+ npm counts as published only when you are a maintainer of the package. mcpship asks `npm whoami`; if you publish with a token instead of `npm login`, set `channels.npm.maintainer` to your npm user name in publish.json.
26
+
27
+ ## What check tests
28
+
29
+ | Rule | Fails when |
30
+ | --- | --- |
31
+ | auth.https | the server URL is not https |
32
+ | auth.www-authenticate | an unauthenticated call does not return 401 with resource_metadata |
33
+ | auth.resource-metadata | protected resource metadata is missing or has no authorization server |
34
+ | auth.as-metadata | authorization server metadata is missing |
35
+ | auth.pkce | S256 is not supported |
36
+ | auth.client-registration | neither Dynamic Client Registration nor Client ID Metadata Documents |
37
+ | tools.reachable | tools/list fails with the given token (skipped without one) |
38
+ | tools.title | a tool has no title |
39
+ | tools.annotations | a tool has neither readOnlyHint nor destructiveHint |
40
+ | tools.instructions | a description tells the model what to do ("use it first", "you must", "call X") |
41
+ | tools.hidden-text | a description holds invisible characters or encoded text |
42
+ | listing.icon-url | the icon URL is not a public https PNG |
43
+ | listing.pages | docs, privacy or terms page does not load |
44
+ | listing.lengths | name, one-liner or description is over the form limit, or a field is empty |
45
+ | listing.unpublished-mention | a page tells users to run `npx <package>` before it is on npm |
46
+
47
+ Warnings: `tools.mentions` (a description names another tool) and `listing.em-dash`.
48
+
49
+ ## Use it from Claude Code
50
+
51
+ /plugin marketplace add emanueltns/mcpship
52
+ /plugin install mcpship@mcpship
53
+
54
+ Then ask Claude to publish your MCP server. The plugin adds the mcpship tools and a skill that walks through each step.
55
+
56
+ ## Privacy
57
+
58
+ mcpship runs on your machine. It calls your server, the URLs in your publish.json, the MCP Registry API, and whatever `npm` and `gh` call. It sends no telemetry. MCPSHIP_TOKEN is read from the environment and never printed.
59
+
60
+ ## License
61
+
62
+ MIT
package/bin/mcpship.js ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+ const commands = require('../lib/commands')
4
+ const { redact, formatCheck, formatStatus, formatNext } = require('../lib/format')
5
+ const { version } = require('../package.json')
6
+
7
+ const USAGE = `mcpship ${version}: check and publish a remote MCP server
8
+
9
+ Usage:
10
+ mcpship init <server-url> Write a publish.json template here
11
+ mcpship check [server-url] Test the server against the Claude directory rules
12
+ mcpship packet Write docs/directory/submission.md (text for each form step)
13
+ mcpship next Show the next unfinished publishing step
14
+ mcpship status Show npm, MCP Registry, plugin and directory status
15
+ mcpship done directory Record that you submitted the directory form
16
+ mcpship mcp Run as a local MCP server (stdio) for agents
17
+
18
+ Options:
19
+ --json Print JSON instead of text
20
+ --dir <path> Folder with publish.json (default: current folder)
21
+
22
+ Environment:
23
+ MCPSHIP_TOKEN Access token or API key, used to list the server's tools. Never printed.`
24
+
25
+ function parse(argv) {
26
+ const out = { cmd: argv[0], args: [], json: false, dir: process.cwd() }
27
+ for (let i = 1; i < argv.length; i++) {
28
+ if (argv[i] === '--json') out.json = true
29
+ else if (argv[i] === '--dir') out.dir = argv[++i]
30
+ else out.args.push(argv[i])
31
+ }
32
+ return out
33
+ }
34
+
35
+ async function main() {
36
+ const argv = process.argv.slice(2)
37
+ if (argv[0] === '--version' || argv[0] === '-v') return console.log(version)
38
+ if (!argv.length || argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') return console.log(USAGE)
39
+ const { cmd, args, json, dir } = parse(argv)
40
+ const token = process.env.MCPSHIP_TOKEN || undefined
41
+ const print = (data, text) => console.log(redact(json ? JSON.stringify(data, null, 2) : text, token))
42
+ try {
43
+ switch (cmd) {
44
+ case 'init': {
45
+ const r = commands.init({ dir, url: args[0] })
46
+ return print(r, `Wrote ${r.path}. Fill in every empty field, then run mcpship check.`)
47
+ }
48
+ case 'check': {
49
+ const r = await commands.check({ dir, url: args[0], token })
50
+ print(r, formatCheck(r))
51
+ process.exitCode = r.summary.fail ? 1 : 0
52
+ return
53
+ }
54
+ case 'packet': {
55
+ const r = await commands.packet({ dir, token })
56
+ return print(r, `Wrote ${r.path}.${r.missing.length ? `\nMissing in publish.json: ${r.missing.join(', ')}` : ''}`)
57
+ }
58
+ case 'next': {
59
+ const r = await commands.next({ dir, token })
60
+ return print(r, formatNext(r))
61
+ }
62
+ case 'status': {
63
+ const r = await commands.status({ dir })
64
+ return print(r, formatStatus(r))
65
+ }
66
+ case 'done': {
67
+ const r = commands.done({ dir, channel: args[0] })
68
+ return print(r, `Recorded: ${r.channel} submitted on ${r.date}.`)
69
+ }
70
+ case 'mcp':
71
+ return require('../lib/mcp').serve(process.stdin, process.stdout, { token })
72
+ default:
73
+ console.error(USAGE)
74
+ process.exitCode = 2
75
+ }
76
+ } catch (e) {
77
+ console.error(redact(`mcpship: ${e.message}`, token))
78
+ process.exitCode = e instanceof commands.UsageError ? 2 : 1
79
+ }
80
+ }
81
+
82
+ main()
@@ -0,0 +1,96 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { run } = require('./exec')
5
+ const { request } = require('./http')
6
+
7
+ const REGISTRY_API = 'https://registry.modelcontextprotocol.io/v0/servers'
8
+ // The registry search took 31-45 s in October 2026 tests.
9
+ const REGISTRY_TIMEOUT_MS = 60000
10
+ const firstLine = s => (s || '').trim().split('\n')[0]
11
+
12
+ const npmUser = m => String(typeof m === 'object' && m ? m.name : m).split(' <')[0].trim().toLowerCase()
13
+
14
+ async function npmStatus(ch, { exec }) {
15
+ if (!ch || !ch.package) return { state: 'not-configured' }
16
+ const res = await exec('npm', ['view', ch.package, 'version', 'maintainers', '--json'])
17
+ if (res.error === 'ENOENT') return { state: 'unknown', detail: 'npm is not installed', fix: 'Install Node.js 20 or newer, which includes npm.' }
18
+ if (res.code === 0 && res.stdout.trim()) {
19
+ let v
20
+ try { v = JSON.parse(res.stdout) } catch { return { state: 'unknown', detail: 'npm view printed something that is not JSON' } }
21
+ const version = Array.isArray(v.version) ? v.version[v.version.length - 1] : v.version
22
+ const maintainers = [].concat(v.maintainers || []).map(npmUser)
23
+ let me = ch.maintainer
24
+ if (!me) {
25
+ const who = await exec('npm', ['whoami'])
26
+ if (who.code !== 0 || !who.stdout.trim()) {
27
+ return { state: 'unknown', version, detail: `${ch.package} ${version} is on npm, but mcpship cannot tell whether it is yours`, fix: 'Run npm login, or add your npm user name as channels.npm.maintainer in publish.json.' }
28
+ }
29
+ me = who.stdout.trim()
30
+ }
31
+ if (!maintainers.includes(npmUser(me))) {
32
+ return { state: 'unknown', version, detail: `${ch.package} on npm belongs to ${maintainers.join(', ') || 'someone else'}, not ${me}`, fix: 'Pick another package name in publish.json (channels.npm.package) and in your package.json.' }
33
+ }
34
+ return { state: 'published', version }
35
+ }
36
+ if (/E404|404 Not Found/.test(res.stderr + res.stdout)) return { state: 'not-published' }
37
+ return { state: 'unknown', detail: firstLine(res.stderr) || res.error || 'npm view failed' }
38
+ }
39
+
40
+ async function registryStatus(ch, { fetchFn, dir }) {
41
+ if (!ch || !ch.name) return { state: 'not-configured' }
42
+ let localVersion
43
+ if (ch.server_json) {
44
+ try { localVersion = JSON.parse(fs.readFileSync(path.resolve(dir, ch.server_json), 'utf8')).version } catch { /* reported as no local version */ }
45
+ }
46
+ try {
47
+ const res = await fetchFn(`${REGISTRY_API}?search=${encodeURIComponent(ch.name)}`, { timeoutMs: REGISTRY_TIMEOUT_MS })
48
+ if (res.status !== 200) return { state: 'unknown', detail: `registry API returned HTTP ${res.status}` }
49
+ const data = JSON.parse(res.text)
50
+ if (!data || !Array.isArray(data.servers)) return { state: 'unknown', detail: 'registry API answered in a shape mcpship does not know' }
51
+ const entries = data.servers.filter(e => e && (e.server || e).name === ch.name)
52
+ const versions = entries.map(e => (e.server || e).version)
53
+ if (!versions.length) return { state: 'not-published', localVersion }
54
+ const flagged = entries.find(e => ((e._meta || {})['io.modelcontextprotocol.registry/official'] || {}).isLatest)
55
+ const latest = flagged ? (flagged.server || flagged).version : versions[versions.length - 1]
56
+ if (localVersion && !versions.includes(localVersion)) return { state: 'outdated', version: latest, localVersion }
57
+ return { state: 'published', version: localVersion || latest }
58
+ } catch (e) {
59
+ return { state: 'unknown', detail: `registry API: ${e.message}` }
60
+ }
61
+ }
62
+
63
+ async function pluginStatus(ch, { exec }) {
64
+ if (!ch || !ch.repo) return { state: 'not-configured' }
65
+ const repo = await exec('gh', ['api', `repos/${ch.repo}`, '--jq', '.visibility'])
66
+ if (repo.error === 'ENOENT') return { state: 'unknown', detail: 'gh is not installed', fix: 'Install the GitHub CLI from https://cli.github.com and run gh auth login.' }
67
+ if (repo.code === 4 || /gh auth login/.test(repo.stderr)) return { state: 'unknown', detail: 'gh is not logged in', fix: 'Run gh auth login.' }
68
+ if (repo.code !== 0) {
69
+ if (/Not Found|HTTP 404/.test(repo.stderr)) return { state: 'not-published', detail: `${ch.repo} does not exist` }
70
+ return { state: 'unknown', detail: firstLine(repo.stderr) || repo.error || 'gh api failed' }
71
+ }
72
+ const visibility = repo.stdout.trim()
73
+ if (visibility !== 'public') return { state: 'not-published', detail: `${ch.repo} is ${visibility}` }
74
+ const mk = await exec('gh', ['api', `repos/${ch.repo}/contents/.claude-plugin/marketplace.json`, '--jq', '.path'])
75
+ if (mk.code !== 0) {
76
+ if (/Not Found|HTTP 404/.test(mk.stderr)) return { state: 'not-published', detail: '.claude-plugin/marketplace.json is missing' }
77
+ return { state: 'unknown', detail: firstLine(mk.stderr) || mk.error || 'gh api failed' }
78
+ }
79
+ return { state: 'published' }
80
+ }
81
+
82
+ function directoryStatus(ch) {
83
+ return ch && ch.submitted ? { state: 'published', date: ch.submitted } : { state: 'not-published' }
84
+ }
85
+
86
+ async function status(cfg, { exec = run, fetchFn = request, dir = process.cwd() } = {}) {
87
+ const ch = cfg.channels || {}
88
+ const [npm, registry, plugin] = await Promise.all([
89
+ npmStatus(ch.npm, { exec }),
90
+ registryStatus(ch.registry, { fetchFn, dir }),
91
+ pluginStatus(ch.plugin, { exec }),
92
+ ])
93
+ return { npm, registry, plugin, directory: directoryStatus(ch.directory) }
94
+ }
95
+
96
+ module.exports = { status, npmStatus, REGISTRY_API }
@@ -0,0 +1,97 @@
1
+ 'use strict'
2
+ const { request, rpc, PROTOCOL } = require('../http')
3
+ const { result } = require('../result')
4
+ const { version } = require('../../package.json')
5
+
6
+ async function getJson(url, timeoutMs) {
7
+ try {
8
+ const res = await request(url, { headers: { accept: 'application/json' }, timeoutMs })
9
+ if (res.status !== 200) return { error: `HTTP ${res.status}` }
10
+ return { json: JSON.parse(res.text) }
11
+ } catch (e) {
12
+ return { error: e instanceof SyntaxError ? 'not JSON' : e.message }
13
+ }
14
+ }
15
+
16
+ // RFC 8414: for an issuer with a path, the well-known segment goes between
17
+ // the host and the path. Some servers only serve the appended form.
18
+ function metadataUrls(issuer) {
19
+ const u = new URL(issuer)
20
+ const p = u.pathname.replace(/\/$/, '')
21
+ const trimmed = issuer.replace(/\/$/, '')
22
+ const urls = []
23
+ for (const name of ['oauth-authorization-server', 'openid-configuration']) {
24
+ urls.push(`${u.origin}/.well-known/${name}${p}`)
25
+ if (p) urls.push(`${trimmed}/.well-known/${name}`)
26
+ }
27
+ return urls
28
+ }
29
+
30
+ async function checkAuth(serverUrl, { timeoutMs } = {}) {
31
+ const results = []
32
+ results.push(serverUrl.startsWith('https://')
33
+ ? result('auth.https', 'pass', 'server URL uses https')
34
+ : result('auth.https', 'fail', `server URL is not https: ${serverUrl}`, 'Serve the MCP endpoint over https. Claude only connects to https:// URLs.'))
35
+
36
+ let first
37
+ try {
38
+ first = await rpc(serverUrl, 'initialize', {
39
+ protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'mcpship', version },
40
+ }, { timeoutMs })
41
+ } catch (e) {
42
+ results.push(result('auth.www-authenticate', 'fail', `server did not answer: ${e.message}`, 'Check the URL and that the server is running.'))
43
+ return { mode: 'unknown', registration: [], results }
44
+ }
45
+ if (first.status === 200) {
46
+ results.push(result('auth.www-authenticate', 'skip', 'server answered without sign-in: no-auth server'))
47
+ return { mode: 'none', registration: [], results }
48
+ }
49
+ const www = first.headers.get('www-authenticate') || ''
50
+ const m = www.match(/resource_metadata="?([^",\s]+)"?/)
51
+ if (first.status !== 401 || !m) {
52
+ results.push(result('auth.www-authenticate', 'fail',
53
+ `unauthenticated initialize returned HTTP ${first.status}${www ? ` with WWW-Authenticate: ${www}` : ' without a WWW-Authenticate header'}`,
54
+ 'Return 401 with WWW-Authenticate: Bearer resource_metadata="<your /.well-known/oauth-protected-resource URL>" so clients can find the sign-in.'))
55
+ return { mode: 'unknown', registration: [], results }
56
+ }
57
+ results.push(result('auth.www-authenticate', 'pass', '401 with resource_metadata'))
58
+
59
+ const prm = await getJson(m[1], timeoutMs)
60
+ const as = prm.json && Array.isArray(prm.json.authorization_servers) && prm.json.authorization_servers[0]
61
+ if (!as) {
62
+ results.push(result('auth.resource-metadata', 'fail', `${m[1]}: ${prm.error || 'authorization_servers is missing or empty'}`,
63
+ 'Serve protected resource metadata (RFC 9728) with a non-empty authorization_servers list.'))
64
+ return { mode: 'oauth', registration: [], results }
65
+ }
66
+ results.push(result('auth.resource-metadata', 'pass', `authorization server ${as}`))
67
+
68
+ let meta = null
69
+ let lastError = ''
70
+ for (const url of metadataUrls(as)) {
71
+ const got = await getJson(url, timeoutMs)
72
+ if (got.json && got.json.authorization_endpoint && got.json.token_endpoint) { meta = got.json; break }
73
+ lastError = got.error || 'authorization_endpoint or token_endpoint missing'
74
+ }
75
+ if (!meta) {
76
+ results.push(result('auth.as-metadata', 'fail', `${as}: ${lastError}`,
77
+ 'Serve authorization server metadata (RFC 8414) at /.well-known/oauth-authorization-server.'))
78
+ return { mode: 'oauth', registration: [], results }
79
+ }
80
+ results.push(result('auth.as-metadata', 'pass', 'authorization server metadata found'))
81
+
82
+ const pk = meta.code_challenge_methods_supported || []
83
+ results.push(pk.includes('S256')
84
+ ? result('auth.pkce', 'pass', 'PKCE S256 supported')
85
+ : result('auth.pkce', 'fail', `code_challenge_methods_supported is ${JSON.stringify(pk)}`, 'Support PKCE with S256 and list it in code_challenge_methods_supported.'))
86
+
87
+ const registration = []
88
+ if (meta.client_id_metadata_document_supported === true) registration.push('Client ID Metadata Document')
89
+ if (meta.registration_endpoint) registration.push('Dynamic Client Registration')
90
+ results.push(registration.length
91
+ ? result('auth.client-registration', 'pass', registration.join(' and '))
92
+ : result('auth.client-registration', 'fail', 'no registration_endpoint, and client_id_metadata_document_supported is not true',
93
+ 'Support Client ID Metadata Documents or Dynamic Client Registration so Claude can register itself.'))
94
+ return { mode: 'oauth', registration, results }
95
+ }
96
+
97
+ module.exports = { checkAuth }
@@ -0,0 +1,93 @@
1
+ 'use strict'
2
+ const { request } = require('../http')
3
+ const { result } = require('../result')
4
+
5
+ const LIMITS = { name: 100, one_liner: 200, description: 2000 }
6
+ const EM_DASH = '\u2014'
7
+ const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
8
+ const len = s => [...s].length
9
+
10
+ async function iconRule(url, { timeoutMs, allowHttp }) {
11
+ const fix = 'The directory form takes the icon as a public URL. Serve a 512x512 PNG from your own site; a private repo link or an HTML page will not load.'
12
+ if (!url) return result('listing.icon-url', 'fail', 'listing.icon_url is empty', fix)
13
+ if (!url.startsWith('https://') && !allowHttp) return result('listing.icon-url', 'fail', `icon URL is not https: ${url}`, fix)
14
+ try {
15
+ const res = await request(url, { timeoutMs, maxBytes: 5_000_000 })
16
+ const type = res.headers.get('content-type') || ''
17
+ if (res.status === 200 && type.startsWith('image/png')) return result('listing.icon-url', 'pass', `icon is a PNG (${res.bytes} bytes)`)
18
+ return result('listing.icon-url', 'fail', `${url}: HTTP ${res.status}, content-type ${type || 'none'}`, fix)
19
+ } catch (e) {
20
+ return result('listing.icon-url', 'fail', `${url}: ${e.message}`, fix)
21
+ }
22
+ }
23
+
24
+ async function pagesRule(L, { timeoutMs }) {
25
+ const problems = []
26
+ for (const key of ['docs_url', 'privacy_url', 'terms_url']) {
27
+ if (!L[key]) { problems.push(`${key} is empty`); continue }
28
+ try {
29
+ const res = await request(L[key], { timeoutMs })
30
+ if (res.status !== 200) problems.push(`${key}: HTTP ${res.status}`)
31
+ } catch (e) {
32
+ problems.push(`${key}: ${e.message}`)
33
+ }
34
+ }
35
+ return problems.length
36
+ ? result('listing.pages', 'fail', problems.join('; '), 'The documentation, privacy and terms pages must be live before you submit.')
37
+ : result('listing.pages', 'pass', 'documentation, privacy and terms pages load')
38
+ }
39
+
40
+ function lengthsRule(cfg, L) {
41
+ const issues = []
42
+ for (const [field, value, max] of [['name', cfg.name, LIMITS.name], ['one_liner', L.one_liner, LIMITS.one_liner], ['description', L.description, LIMITS.description]]) {
43
+ if (!value) issues.push(`${field} is empty`)
44
+ else if (len(value) > max) issues.push(`${field} is ${len(value)} characters (limit ${max})`)
45
+ }
46
+ const cats = Array.isArray(L.categories) ? L.categories.filter(Boolean) : []
47
+ if (cats.length < 1 || cats.length > 5) issues.push(`categories has ${cats.length} entries (1 to 5)`)
48
+ const ucs = (cfg.use_cases || []).filter(u => u && u.use_case)
49
+ if (ucs.length < 1 || ucs.length > 3) issues.push(`use_cases has ${ucs.length} filled entries (1 to 3)`)
50
+ return issues.length
51
+ ? result('listing.lengths', 'fail', issues.join('; '), 'Fix these fields in publish.json.')
52
+ : result('listing.lengths', 'pass', 'name, one-liner, description, categories and use cases fit the form')
53
+ }
54
+
55
+ async function mentionRule(cfg, npmState, { timeoutMs }) {
56
+ const pkg = cfg.channels && cfg.channels.npm && cfg.channels.npm.package
57
+ if (!pkg) return result('listing.unpublished-mention', 'skip', 'no npm package configured')
58
+ if (npmState === 'published') return result('listing.unpublished-mention', 'pass', `${pkg} is on npm`)
59
+ if (npmState !== 'not-published') return result('listing.unpublished-mention', 'skip', 'npm status unknown')
60
+ const re = new RegExp(`npx (-y )?${esc(pkg)}(?![\\w.-])`)
61
+ const hits = []
62
+ for (const url of cfg.pages_to_scan || []) {
63
+ try {
64
+ const res = await request(url, { timeoutMs })
65
+ if (re.test(res.text)) hits.push(url)
66
+ } catch { /* an unreachable page cannot advertise anything */ }
67
+ }
68
+ return hits.length
69
+ ? result('listing.unpublished-mention', 'fail', `${hits.join(', ')} tell users to run npx ${pkg}, but ${pkg} is not on npm yet`,
70
+ 'Remove the line until the package is published. Anyone could register the name and collect what users paste into it.')
71
+ : result('listing.unpublished-mention', 'pass', `no page mentions npx ${pkg}`)
72
+ }
73
+
74
+ function emDashRule(cfg, L) {
75
+ const text = [cfg.name, L.one_liner, L.description, cfg.prerequisites, cfg.reviewer && cfg.reviewer.instructions,
76
+ ...(cfg.use_cases || []).flatMap(u => [u && u.use_case, u && u.prompt])].filter(Boolean).join('\n')
77
+ return text.includes(EM_DASH)
78
+ ? result('listing.em-dash', 'warn', 'listing text contains an em-dash', 'Replace it with a comma, colon or full stop before pasting.')
79
+ : result('listing.em-dash', 'pass', 'no em-dash in listing text')
80
+ }
81
+
82
+ async function checkListing(cfg, { npmState, timeoutMs, allowHttp = false } = {}) {
83
+ const L = cfg.listing || {}
84
+ return [
85
+ await iconRule(L.icon_url, { timeoutMs, allowHttp }),
86
+ await pagesRule(L, { timeoutMs }),
87
+ lengthsRule(cfg, L),
88
+ await mentionRule(cfg, npmState, { timeoutMs }),
89
+ emDashRule(cfg, L),
90
+ ]
91
+ }
92
+
93
+ module.exports = { checkListing, LIMITS }
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+ const { rpc, notify, PROTOCOL } = require('../http')
3
+ const { result } = require('../result')
4
+ const { lintTools } = require('../lint')
5
+ const { version } = require('../../package.json')
6
+
7
+ const MAX_PAGES = 10
8
+
9
+ function evaluateTools(tools) {
10
+ const noTitle = tools.filter(t => !t.title && !(t.annotations && t.annotations.title)).map(t => t.name)
11
+ const noHint = tools.filter(t => {
12
+ const a = t.annotations || {}
13
+ return typeof a.readOnlyHint !== 'boolean' && typeof a.destructiveHint !== 'boolean'
14
+ }).map(t => t.name)
15
+ return [
16
+ noTitle.length
17
+ ? result('tools.title', 'fail', `missing title: ${noTitle.join(', ')}`, 'Add a human-readable title to every tool (tool.title or annotations.title).')
18
+ : result('tools.title', 'pass', `all ${tools.length} tools have a title`),
19
+ noHint.length
20
+ ? result('tools.annotations', 'fail', `missing readOnlyHint or destructiveHint: ${noHint.join(', ')}`, 'Set annotations.readOnlyHint: true on tools that only read, and annotations.destructiveHint on tools that change data.')
21
+ : result('tools.annotations', 'pass', 'every tool declares readOnlyHint or destructiveHint'),
22
+ ...lintTools(tools),
23
+ ]
24
+ }
25
+
26
+ async function checkTools(serverUrl, { token, authMode, timeoutMs } = {}) {
27
+ if (authMode === 'oauth' && !token) {
28
+ return { tools: null, results: [result('tools.reachable', 'skip', 'set MCPSHIP_TOKEN to an access token or API key to check tools')] }
29
+ }
30
+ const fail = (message) => ({
31
+ tools: null,
32
+ results: [result('tools.reachable', 'fail', message,
33
+ authMode === 'oauth' ? 'Check that MCPSHIP_TOKEN is a valid access token or API key for this server.' : 'Check that the server answers initialize and tools/list.')],
34
+ })
35
+ try {
36
+ const init = await rpc(serverUrl, 'initialize', {
37
+ protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'mcpship', version },
38
+ }, { token, timeoutMs, id: 1 })
39
+ if (init.status !== 200 || !init.message || init.message.error) {
40
+ const why = init.message && init.message.error ? `: ${init.message.error.message}` : ''
41
+ return fail(`initialize returned HTTP ${init.status}${why}`)
42
+ }
43
+ const sessionId = init.headers.get('mcp-session-id') || undefined
44
+ await notify(serverUrl, 'notifications/initialized', { token, sessionId, timeoutMs }).catch(() => {})
45
+ const tools = []
46
+ let cursor
47
+ for (let page = 0; page < MAX_PAGES; page++) {
48
+ const res = await rpc(serverUrl, 'tools/list', cursor ? { cursor } : {}, { token, sessionId, timeoutMs, id: page + 2 })
49
+ if (res.status !== 200 || !res.message || !res.message.result) {
50
+ const why = res.message && res.message.error ? `: ${res.message.error.message}` : ''
51
+ return fail(`tools/list returned HTTP ${res.status}${why}`)
52
+ }
53
+ tools.push(...(res.message.result.tools || []))
54
+ cursor = res.message.result.nextCursor
55
+ if (!cursor) break
56
+ }
57
+ return { tools, results: [result('tools.reachable', 'pass', `${tools.length} tools listed`), ...evaluateTools(tools)] }
58
+ } catch (e) {
59
+ return fail(`server did not answer: ${e.message}`)
60
+ }
61
+ }
62
+
63
+ module.exports = { checkTools, evaluateTools }
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const config = require('./config')
5
+ const channels = require('./channels')
6
+ const { checkAuth } = require('./checks/auth')
7
+ const { checkTools } = require('./checks/tools')
8
+ const { checkListing } = require('./checks/listing')
9
+ const { renderPacket, writePacket, PACKET_PATH } = require('./packet')
10
+ const { nextStep } = require('./next')
11
+ const { summarize } = require('./result')
12
+ const { run } = require('./exec')
13
+
14
+ class UsageError extends Error {}
15
+
16
+ function loadOrThrow(dir) {
17
+ const loaded = config.load(dir)
18
+ if (!loaded.config) throw new UsageError(loaded.errors.join('\n'))
19
+ return loaded.config
20
+ }
21
+
22
+ async function check({ dir, url, token, deps = {} }) {
23
+ const loaded = config.load(dir)
24
+ if (loaded.exists && !loaded.config) throw new UsageError(loaded.errors.join('\n'))
25
+ const cfg = loaded.config
26
+ const serverUrl = url || (cfg && cfg.server_url)
27
+ if (!serverUrl) throw new UsageError('give a server URL (mcpship check <url>) or create publish.json with mcpship init <url>')
28
+ const auth = await checkAuth(serverUrl)
29
+ const tools = await checkTools(serverUrl, { token, authMode: auth.mode })
30
+ let listing = []
31
+ if (cfg) {
32
+ const npm = await channels.npmStatus(cfg.channels && cfg.channels.npm, { exec: deps.exec || run })
33
+ listing = await checkListing(cfg, { npmState: npm.state })
34
+ }
35
+ const results = [...auth.results, ...tools.results, ...listing]
36
+ return { serverUrl, auth: { mode: auth.mode, registration: auth.registration }, tools: tools.tools, results, summary: summarize(results) }
37
+ }
38
+
39
+ async function packet({ dir, token, deps }) {
40
+ const cfg = loadOrThrow(dir)
41
+ const c = await check({ dir, token, deps })
42
+ const { text, missing } = renderPacket(cfg, { auth: c.auth, tools: c.tools })
43
+ return { path: writePacket(dir, text), missing }
44
+ }
45
+
46
+ async function status({ dir, deps = {} }) {
47
+ const cfg = loadOrThrow(dir)
48
+ return channels.status(cfg, { ...deps, dir })
49
+ }
50
+
51
+ async function next({ dir, token, deps = {} }) {
52
+ const cfg = loadOrThrow(dir)
53
+ const [c, st] = await Promise.all([check({ dir, token, deps }), channels.status(cfg, { ...deps, dir })])
54
+ return nextStep({ cfg, check: c.summary, channels: st, packetExists: fs.existsSync(path.join(dir, PACKET_PATH)) })
55
+ }
56
+
57
+ function done({ dir, channel }) {
58
+ if (channel !== 'directory') {
59
+ throw new UsageError(`only the directory is confirmed by hand; ${channel || 'that channel'} is detected automatically (see mcpship status)`)
60
+ }
61
+ const cfg = loadOrThrow(dir)
62
+ const date = new Date().toISOString().slice(0, 10)
63
+ cfg.channels = cfg.channels || {}
64
+ cfg.channels.directory = { ...(cfg.channels.directory || {}), submitted: date }
65
+ config.save(dir, cfg)
66
+ return { channel, date }
67
+ }
68
+
69
+ function init({ dir, url }) {
70
+ if (!url) throw new UsageError('give the server URL: mcpship init https://example.com/mcp')
71
+ const file = path.join(dir, config.FILE)
72
+ if (fs.existsSync(file)) throw new UsageError(`${config.FILE} already exists in ${dir}`)
73
+ config.save(dir, config.template(url))
74
+ return { path: file }
75
+ }
76
+
77
+ module.exports = { UsageError, check, packet, next, status, done, init }
package/lib/config.js ADDED
@@ -0,0 +1,74 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ const FILE = 'publish.json'
6
+ const ACCESS = ['read', 'write', 'both']
7
+ const OWNERSHIP = ['own', 'partner', 'third_party']
8
+
9
+ function validate(cfg) {
10
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return [`${FILE} must be a JSON object`]
11
+ const errors = []
12
+ if (typeof cfg.server_url !== 'string' || !cfg.server_url) errors.push('server_url is required')
13
+ if (cfg.access !== undefined && cfg.access !== '' && !ACCESS.includes(cfg.access)) {
14
+ errors.push(`access must be one of ${ACCESS.join(', ')}`)
15
+ }
16
+ const own = cfg.data_handling && cfg.data_handling.api_ownership
17
+ if (own !== undefined && own !== '' && !OWNERSHIP.includes(own)) {
18
+ errors.push(`data_handling.api_ownership must be one of ${OWNERSHIP.join(', ')}`)
19
+ }
20
+ for (const k of ['health_data', 'sponsored']) {
21
+ const v = cfg.data_handling && cfg.data_handling[k]
22
+ if (v !== undefined && v !== null && typeof v !== 'boolean') errors.push(`data_handling.${k} must be true or false`)
23
+ }
24
+ const npm = cfg.channels && cfg.channels.npm
25
+ if (npm && npm.maintainer !== undefined && typeof npm.maintainer !== 'string') errors.push('channels.npm.maintainer must be a string')
26
+ if (cfg.use_cases !== undefined && !Array.isArray(cfg.use_cases)) errors.push('use_cases must be a list')
27
+ if (cfg.channels !== undefined && (typeof cfg.channels !== 'object' || Array.isArray(cfg.channels))) {
28
+ errors.push('channels must be an object')
29
+ }
30
+ return errors
31
+ }
32
+
33
+ function load(dir) {
34
+ const file = path.join(dir, FILE)
35
+ let raw
36
+ try {
37
+ raw = fs.readFileSync(file, 'utf8')
38
+ } catch {
39
+ return { exists: false, config: null, errors: [`${FILE} not found in ${dir} (create one with: mcpship init <server-url>)`] }
40
+ }
41
+ let cfg
42
+ try {
43
+ cfg = JSON.parse(raw)
44
+ } catch (e) {
45
+ return { exists: true, config: null, errors: [`${FILE} is not valid JSON: ${e.message}`] }
46
+ }
47
+ const errors = validate(cfg)
48
+ return { exists: true, config: errors.length ? null : cfg, errors }
49
+ }
50
+
51
+ function save(dir, cfg) {
52
+ fs.writeFileSync(path.join(dir, FILE), JSON.stringify(cfg, null, 2) + '\n')
53
+ }
54
+
55
+ function template(serverUrl) {
56
+ return {
57
+ name: '',
58
+ server_url: serverUrl,
59
+ listing: {
60
+ one_liner: '', description: '', categories: [],
61
+ docs_url: '', privacy_url: '', terms_url: '', support_email: '', icon_url: '',
62
+ },
63
+ use_cases: [{ use_case: '', prompt: '' }],
64
+ prerequisites: '',
65
+ access: '',
66
+ company: { name: '', website: '', contact_name: '', contact_email: '', role: '' },
67
+ data_handling: { api_ownership: '', health_data: null, sponsored: null, notes: '' },
68
+ reviewer: { instructions: '' },
69
+ pages_to_scan: [],
70
+ channels: {},
71
+ }
72
+ }
73
+
74
+ module.exports = { FILE, validate, load, save, template }
package/lib/exec.js ADDED
@@ -0,0 +1,14 @@
1
+ 'use strict'
2
+ const { execFile } = require('child_process')
3
+
4
+ function run(cmd, args, { timeoutMs = 30000, cwd } = {}) {
5
+ return new Promise(resolve => {
6
+ execFile(cmd, args, { timeout: timeoutMs, cwd, maxBuffer: 4_000_000 }, (err, stdout, stderr) => {
7
+ if (err && err.code === 'ENOENT') return resolve({ code: null, stdout: '', stderr: '', error: 'ENOENT' })
8
+ if (err && err.killed) return resolve({ code: null, stdout: stdout || '', stderr: stderr || '', error: 'timeout' })
9
+ resolve({ code: err ? (typeof err.code === 'number' ? err.code : 1) : 0, stdout: stdout || '', stderr: stderr || '' })
10
+ })
11
+ })
12
+ }
13
+
14
+ module.exports = { run }
package/lib/format.js ADDED
@@ -0,0 +1,34 @@
1
+ 'use strict'
2
+
3
+ function redact(text, token) {
4
+ if (!token) return String(text)
5
+ const escaped = JSON.stringify(token).slice(1, -1)
6
+ return String(text).split(escaped).join('***').split(token).join('***')
7
+ }
8
+
9
+ const LABEL = { pass: 'PASS', fail: 'FAIL', warn: 'WARN', skip: 'SKIP' }
10
+
11
+ function formatCheck(data) {
12
+ const lines = [`Server: ${data.serverUrl}`, `Sign-in: ${data.auth.mode}`, '']
13
+ for (const r of data.results) {
14
+ lines.push(`${LABEL[r.status].padEnd(5)} ${r.id.padEnd(28)} ${r.message}`)
15
+ if (r.fix && (r.status === 'fail' || r.status === 'warn')) lines.push(` fix: ${r.fix}`)
16
+ }
17
+ const s = data.summary
18
+ lines.push('', `${s.pass} passed, ${s.fail} failed, ${s.warn} warnings, ${s.skip} skipped`)
19
+ return lines.join('\n')
20
+ }
21
+
22
+ function formatStatus(st) {
23
+ const row = (name, c) => {
24
+ const extra = [c.version && `version ${c.version}`, c.localVersion && `local ${c.localVersion}`, c.date && `on ${c.date}`, c.detail].filter(Boolean).join(', ')
25
+ return `${name.padEnd(10)} ${c.state}${extra ? ` (${extra})` : ''}${c.fix ? `\n fix: ${c.fix}` : ''}`
26
+ }
27
+ return [row('npm', st.npm), row('registry', st.registry), row('plugin', st.plugin), row('directory', st.directory)].join('\n')
28
+ }
29
+
30
+ function formatNext(n) {
31
+ return [`Next: ${n.title}`, '', ...n.instructions.map(l => (l.startsWith(' ') ? l : `- ${l}`))].join('\n')
32
+ }
33
+
34
+ module.exports = { redact, formatCheck, formatStatus, formatNext }
package/lib/http.js ADDED
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+ const { version } = require('../package.json')
3
+
4
+ const PROTOCOL = '2025-06-18'
5
+
6
+ async function request(url, { method = 'GET', headers = {}, body, timeoutMs = 10000, maxBytes = 1_000_000 } = {}) {
7
+ const ctrl = new AbortController()
8
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs)
9
+ try {
10
+ const res = await fetch(url, {
11
+ method, body, redirect: 'follow', signal: ctrl.signal,
12
+ headers: { 'user-agent': `mcpship/${version}`, ...headers },
13
+ })
14
+ const buf = Buffer.from(await res.arrayBuffer())
15
+ if (buf.length > maxBytes) throw new Error(`response larger than ${maxBytes} bytes`)
16
+ return { status: res.status, headers: res.headers, text: buf.toString('utf8'), bytes: buf.length }
17
+ } catch (e) {
18
+ if (e.name === 'AbortError') throw new Error('timeout')
19
+ if (e.cause && e.cause.code) throw new Error(e.cause.code)
20
+ throw e
21
+ } finally {
22
+ clearTimeout(timer)
23
+ }
24
+ }
25
+
26
+ function parseRpc(text, contentType = '') {
27
+ if (contentType.includes('text/event-stream')) {
28
+ let last = null
29
+ for (const line of text.split(/\r?\n/)) {
30
+ if (!line.startsWith('data:')) continue
31
+ try {
32
+ const m = JSON.parse(line.slice(5).trim())
33
+ if (m && (m.result !== undefined || m.error !== undefined)) last = m
34
+ } catch { /* a non-JSON data line is not the response */ }
35
+ }
36
+ return last
37
+ }
38
+ try { return JSON.parse(text) } catch { return null }
39
+ }
40
+
41
+ function rpcHeaders(token, sessionId) {
42
+ const h = {
43
+ 'content-type': 'application/json',
44
+ accept: 'application/json, text/event-stream',
45
+ 'mcp-protocol-version': PROTOCOL,
46
+ }
47
+ if (token) h.authorization = `Bearer ${token}`
48
+ if (sessionId) h['mcp-session-id'] = sessionId
49
+ return h
50
+ }
51
+
52
+ async function rpc(url, method, params, { token, id = 1, sessionId, timeoutMs } = {}) {
53
+ const res = await request(url, {
54
+ method: 'POST', timeoutMs, headers: rpcHeaders(token, sessionId),
55
+ body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
56
+ })
57
+ return { status: res.status, headers: res.headers, message: parseRpc(res.text, res.headers.get('content-type') || '') }
58
+ }
59
+
60
+ async function notify(url, method, { token, sessionId, timeoutMs } = {}) {
61
+ await request(url, {
62
+ method: 'POST', timeoutMs, headers: rpcHeaders(token, sessionId),
63
+ body: JSON.stringify({ jsonrpc: '2.0', method }),
64
+ })
65
+ }
66
+
67
+ module.exports = { PROTOCOL, request, parseRpc, rpc, notify }
package/lib/lint.js ADDED
@@ -0,0 +1,51 @@
1
+ 'use strict'
2
+ const { result } = require('./result')
3
+
4
+ // The directory compliance step: tool descriptions hold no instructions about
5
+ // model behaviour or other tools, and no hidden or encoded text.
6
+ const INSTRUCTION_PATTERNS = [
7
+ /\buse (it|this|this tool) (first|before|after|instead)\b/i,
8
+ /\b(always|never) (call|use|invoke)\b/i,
9
+ /\byou (must|should)\b/i,
10
+ /\bignore (all|previous|the above)\b/i,
11
+ /\bsystem prompt\b/i,
12
+ ]
13
+ const HIDDEN = /[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/
14
+ const BASE64 = /[A-Za-z0-9+/]{40,}={0,2}/
15
+ const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
16
+
17
+ function lintTools(tools) {
18
+ const instructions = []
19
+ const hidden = []
20
+ const mentions = []
21
+ const names = tools.map(t => t.name)
22
+ for (const t of tools) {
23
+ const d = t.description || ''
24
+ for (const p of INSTRUCTION_PATTERNS) {
25
+ const m = d.match(p)
26
+ if (m) instructions.push(`${t.name}: "${m[0]}"`)
27
+ }
28
+ for (const other of names) {
29
+ if (other === t.name) continue
30
+ const call = d.match(new RegExp(`\\b(call|use|invoke|run) (the )?\`?${esc(other)}\\b`, 'i'))
31
+ if (call) instructions.push(`${t.name}: "${call[0]}"`)
32
+ else if (new RegExp(`\\b${esc(other)}\\b`).test(d)) mentions.push(`${t.name} names ${other}`)
33
+ }
34
+ if (HIDDEN.test(d)) hidden.push(`${t.name}: invisible character`)
35
+ const b = d.match(BASE64)
36
+ if (b) hidden.push(`${t.name}: encoded-looking text "${b[0].slice(0, 20)}..."`)
37
+ }
38
+ return [
39
+ instructions.length
40
+ ? result('tools.instructions', 'fail', instructions.join('; '), 'Describe what the tool does and returns, as a fact. Remove sentences that tell the model what to do or which tool to call.')
41
+ : result('tools.instructions', 'pass', 'no instructions in tool descriptions'),
42
+ hidden.length
43
+ ? result('tools.hidden-text', 'fail', hidden.join('; '), 'Remove invisible characters and encoded text from descriptions.')
44
+ : result('tools.hidden-text', 'pass', 'no hidden or encoded text'),
45
+ mentions.length
46
+ ? result('tools.mentions', 'warn', mentions.join('; '), 'Usually accepted. If a reviewer objects, describe the behaviour without naming the other tool.')
47
+ : result('tools.mentions', 'pass', 'no description names another tool'),
48
+ ]
49
+ }
50
+
51
+ module.exports = { lintTools, INSTRUCTION_PATTERNS }
package/lib/mcp.js ADDED
@@ -0,0 +1,115 @@
1
+ 'use strict'
2
+ const readline = require('readline')
3
+ const commands = require('./commands')
4
+ const { redact, formatCheck, formatStatus, formatNext } = require('./format')
5
+ const { version } = require('../package.json')
6
+
7
+ const VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26']
8
+ const DIR = { type: 'string', description: 'Folder that holds publish.json. Defaults to the folder the server was started in.' }
9
+ const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
10
+ const LOCAL_WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }
11
+
12
+ const TOOLS = [
13
+ {
14
+ name: 'mcpship_check',
15
+ title: 'Check an MCP server against the Claude directory rules',
16
+ description: 'Tests a remote MCP server: OAuth discovery, PKCE and client registration, tool titles and annotations, instruction-free descriptions, and the listing (icon URL, pages, field lengths). Lists tools when MCPSHIP_TOKEN is set in the environment. Returns each rule as pass, fail, warn or skip, with a fix for every failure.',
17
+ inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'Server URL. Defaults to server_url in publish.json.' }, dir: DIR } },
18
+ annotations: { title: 'Check server', ...READ },
19
+ },
20
+ {
21
+ name: 'mcpship_next',
22
+ title: 'Next publishing step',
23
+ description: 'Returns the first unfinished publishing step (check, npm, MCP Registry, plugin repo, submission text, directory) with the exact command or click for it. Reads the live state of every channel.',
24
+ inputSchema: { type: 'object', properties: { dir: DIR } },
25
+ annotations: { title: 'Next step', ...READ },
26
+ },
27
+ {
28
+ name: 'mcpship_status',
29
+ title: 'Publishing status',
30
+ description: 'Returns the live state of npm, the MCP Registry, the Claude Code plugin repo and the Claude directory submission for the project in publish.json.',
31
+ inputSchema: { type: 'object', properties: { dir: DIR } },
32
+ annotations: { title: 'Status', ...READ },
33
+ },
34
+ {
35
+ name: 'mcpship_packet',
36
+ title: 'Write the directory submission text',
37
+ description: 'Writes docs/directory/submission.md next to publish.json: the text for each step of the Claude directory form, in portal order. Fields missing from publish.json are marked MISSING.',
38
+ inputSchema: { type: 'object', properties: { dir: DIR } },
39
+ annotations: { title: 'Write packet', ...LOCAL_WRITE },
40
+ },
41
+ {
42
+ name: 'mcpship_done',
43
+ title: 'Record the directory submission',
44
+ description: 'Records in publish.json that the Claude directory form was submitted today. The directory has no public status API, so this is the only manually confirmed step.',
45
+ inputSchema: { type: 'object', properties: { channel: { type: 'string', enum: ['directory'] }, dir: DIR }, required: ['channel'] },
46
+ annotations: { title: 'Mark submitted', ...LOCAL_WRITE },
47
+ },
48
+ ]
49
+
50
+ async function callTool(name, args, token) {
51
+ const dir = args.dir || process.cwd()
52
+ switch (name) {
53
+ case 'mcpship_check': { const r = await commands.check({ dir, url: args.url, token }); return [r, formatCheck(r)] }
54
+ case 'mcpship_next': { const r = await commands.next({ dir, token }); return [r, formatNext(r)] }
55
+ case 'mcpship_status': { const r = await commands.status({ dir }); return [r, formatStatus(r)] }
56
+ case 'mcpship_packet': {
57
+ const r = await commands.packet({ dir, token })
58
+ return [r, `Wrote ${r.path}.${r.missing.length ? ` Missing in publish.json: ${r.missing.join(', ')}` : ''}`]
59
+ }
60
+ case 'mcpship_done': { const r = commands.done({ dir, channel: args.channel }); return [r, `Recorded: ${r.channel} submitted on ${r.date}.`] }
61
+ default: return null
62
+ }
63
+ }
64
+
65
+ async function handle(msg, { token }) {
66
+ const reply = result => ({ jsonrpc: '2.0', id: msg.id, result })
67
+ const error = (code, message) => ({ jsonrpc: '2.0', id: msg.id, error: { code, message } })
68
+ if (msg.id === undefined) return null
69
+ switch (msg.method) {
70
+ case 'initialize': {
71
+ const asked = msg.params && msg.params.protocolVersion
72
+ return reply({
73
+ protocolVersion: VERSIONS.includes(asked) ? asked : VERSIONS[0],
74
+ capabilities: { tools: {} },
75
+ serverInfo: { name: 'mcpship', version },
76
+ })
77
+ }
78
+ case 'ping': return reply({})
79
+ case 'tools/list': return reply({ tools: TOOLS })
80
+ case 'tools/call': {
81
+ const p = msg.params || {}
82
+ if (!TOOLS.some(t => t.name === p.name)) return error(-32602, `Unknown tool: ${p.name}`)
83
+ try {
84
+ const [data, text] = await callTool(p.name, p.arguments || {}, token)
85
+ return reply({ content: [{ type: 'text', text: redact(text, token) }], structuredContent: JSON.parse(redact(JSON.stringify(data), token)) })
86
+ } catch (e) {
87
+ return reply({ content: [{ type: 'text', text: redact(e.message, token) }], isError: true })
88
+ }
89
+ }
90
+ default: return error(-32601, `Method not found: ${msg.method}`)
91
+ }
92
+ }
93
+
94
+ function serve(input, output, { token } = {}) {
95
+ const rl = readline.createInterface({ input })
96
+ rl.on('line', async line => {
97
+ if (!line.trim()) return
98
+ let msg
99
+ try { msg = JSON.parse(line) } catch {
100
+ output.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n')
101
+ return
102
+ }
103
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
104
+ output.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid request: send one JSON-RPC object per line' } }) + '\n')
105
+ return
106
+ }
107
+ let out
108
+ try { out = await handle(msg, { token }) } catch (e) {
109
+ out = { jsonrpc: '2.0', id: msg.id === undefined ? null : msg.id, error: { code: -32603, message: redact(e.message, token) } }
110
+ }
111
+ if (out) output.write(JSON.stringify(out) + '\n')
112
+ })
113
+ }
114
+
115
+ module.exports = { serve, handle, TOOLS }
package/lib/next.js ADDED
@@ -0,0 +1,90 @@
1
+ 'use strict'
2
+
3
+ const unknownLines = st => (st.state === 'unknown' ? [`Status unknown: ${st.detail || 'no detail'}.`, ...(st.fix ? [st.fix] : [])] : [])
4
+
5
+ function npmStep(ch, st) {
6
+ return {
7
+ step: 'npm',
8
+ title: `Publish ${ch.package} to npm`,
9
+ instructions: [
10
+ ...unknownLines(st),
11
+ `Publish ${ch.package} from its package folder.`,
12
+ 'With two-factor authentication on, the smoothest way is a Granular Access Token: on npmjs.com open Access Tokens, Generate New Token, Granular Access Token, give it read and write access to packages and tick "Bypass two-factor authentication".',
13
+ 'Keep the token in the NPM_TOKEN environment variable, then publish through a temporary config file and delete it:',
14
+ ' (umask 077; printf "//registry.npmjs.org/:_authToken=%s\\n" "$NPM_TOKEN" > /tmp/mcpship-npmrc) && npm publish --access public --userconfig /tmp/mcpship-npmrc; rm -f /tmp/mcpship-npmrc',
15
+ 'Fallback: npm publish --access public --otp=123456, with the current 6-digit code from the authenticator app you set up for npm. The code changes every 30 seconds, and recovery codes do not work here.',
16
+ 'Then run mcpship status: npm shows as published.',
17
+ ],
18
+ }
19
+ }
20
+
21
+ function registryStep(ch, st) {
22
+ return {
23
+ step: 'registry',
24
+ title: `Publish ${ch.name} to the MCP Registry`,
25
+ instructions: [
26
+ ...unknownLines(st),
27
+ ...(st.state === 'outdated' ? [`The registry has ${st.version} and your server.json says ${st.localVersion}: publish the new version.`] : []),
28
+ 'Install mcp-publisher if you do not have it: download it for your system from https://github.com/modelcontextprotocol/registry/releases.',
29
+ `In the folder with server.json${ch.server_json ? ` (${ch.server_json})` : ''}, run: mcp-publisher login github, then mcp-publisher publish.`,
30
+ 'With GitHub login the name must start with io.github.<your-github-user>/, and the description must be 100 characters or fewer.',
31
+ 'Then run mcpship status: the registry shows as published.',
32
+ ],
33
+ }
34
+ }
35
+
36
+ function pluginStep(ch, st, cfg) {
37
+ return {
38
+ step: 'plugin',
39
+ title: `Publish the Claude Code plugin repo ${ch.repo}`,
40
+ instructions: [
41
+ ...unknownLines(st),
42
+ ...(st.detail && st.state !== 'unknown' ? [`Now: ${st.detail}.`] : []),
43
+ 'The repo needs .claude-plugin/marketplace.json listing your plugin, and plugins/<name>/.claude-plugin/plugin.json plus plugins/<name>/.mcp.json containing:',
44
+ ` {"mcpServers": {"<name>": {"type": "http", "url": "${cfg.server_url}"}}}`,
45
+ `Create it public and push: gh repo create ${ch.repo} --public --source . --push`,
46
+ `Users then install it with /plugin marketplace add ${ch.repo} and /plugin install <name>@<marketplace-name>.`,
47
+ ],
48
+ }
49
+ }
50
+
51
+ function nextStep({ cfg, check, channels, packetExists }) {
52
+ if (!check || check.fail > 0) {
53
+ return {
54
+ step: 'check',
55
+ title: 'Make mcpship check pass',
56
+ instructions: check
57
+ ? [`${check.fail} rule(s) fail. Run mcpship check and fix each one; every failure prints its fix.`]
58
+ : ['Run mcpship check. Set MCPSHIP_TOKEN to an access token or API key so the tools are checked too.'],
59
+ }
60
+ }
61
+ const ch = cfg.channels || {}
62
+ if (ch.npm && channels.npm.state !== 'published') return npmStep(ch.npm, channels.npm)
63
+ if (ch.registry && channels.registry.state !== 'published') return registryStep(ch.registry, channels.registry)
64
+ if (ch.plugin && channels.plugin.state !== 'published') return pluginStep(ch.plugin, channels.plugin, cfg)
65
+ if (!packetExists) {
66
+ return {
67
+ step: 'packet',
68
+ title: 'Write the directory submission text',
69
+ instructions: ['Run mcpship packet. It writes docs/directory/submission.md with the text for every form step. Fix any MISSING line in publish.json and run it again.'],
70
+ }
71
+ }
72
+ if (channels.directory.state !== 'published') {
73
+ return {
74
+ step: 'directory',
75
+ title: 'Submit to the Claude connector directory',
76
+ instructions: [
77
+ 'Open https://claude.ai/directory/manage, choose "Submit new", then "MCP connector". Any paid Claude plan can submit.',
78
+ 'Paste each step from docs/directory/submission.md. The icon is a public URL, not a file.',
79
+ 'Afterwards run mcpship done directory.',
80
+ ],
81
+ }
82
+ }
83
+ return {
84
+ step: 'done',
85
+ title: 'Everything is published',
86
+ instructions: ['npm, the MCP Registry, the plugin and the directory submission are done. Run mcpship status any time to re-check.'],
87
+ }
88
+ }
89
+
90
+ module.exports = { nextStep }
package/lib/packet.js ADDED
@@ -0,0 +1,119 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { LIMITS } = require('./checks/listing')
5
+
6
+ const PACKET_PATH = 'docs/directory/submission.md'
7
+ const ACCESS_LABEL = { read: 'Read only', write: 'Write only', both: 'Read and write' }
8
+ const OWN_LABEL = { own: 'We own the API', partner: "We proxy a partner's API with permission", third_party: "Calls a third-party API we don't control" }
9
+
10
+ function renderPacket(cfg, { auth = null, tools = null } = {}) {
11
+ const missing = []
12
+ const val = (v, field) => {
13
+ if (v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length)) {
14
+ missing.push(field)
15
+ return `MISSING: ${field}`
16
+ }
17
+ return Array.isArray(v) ? v.join(', ') : String(v)
18
+ }
19
+ const count = (v, max) => (v.startsWith('MISSING: ') ? '' : ` (${[...v].length}/${max} characters)`)
20
+ const yesNo = (v, field) => (typeof v === 'boolean' ? (v ? 'Yes' : 'No') : val(null, field))
21
+ const L = cfg.listing || {}
22
+ const C = cfg.company || {}
23
+ const D = cfg.data_handling || {}
24
+ const out = []
25
+ const push = (...lines) => out.push(...lines)
26
+
27
+ push(`# Claude connector directory: submission text for ${cfg.name || 'this server'}`, '',
28
+ 'Generated by mcpship. Open https://claude.ai/directory/manage, choose "Submit new", then "MCP connector", and paste each value into the step with the same name. Any paid Claude plan can submit.', '')
29
+
30
+ push('## 1. Connection', '', `- Server URL: ${val(cfg.server_url, 'server_url')}`, '')
31
+
32
+ push('## 2. Tools', '')
33
+ if (tools) {
34
+ push('The portal syncs these from the server. Every tool needs a title and a readOnlyHint or destructiveHint.', '',
35
+ '| Tool | Title | Read only | Destructive |', '| --- | --- | --- | --- |')
36
+ for (const t of tools) {
37
+ const a = t.annotations || {}
38
+ const show = v => (v === undefined ? '' : String(v))
39
+ push(`| ${t.name} | ${t.title || a.title || ''} | ${show(a.readOnlyHint)} | ${show(a.destructiveHint)} |`)
40
+ }
41
+ push('')
42
+ } else {
43
+ push('Not listed: run `mcpship check` with MCPSHIP_TOKEN set to include the tool table.', '')
44
+ }
45
+
46
+ const name = val(cfg.name, 'name')
47
+ const one = val(L.one_liner, 'listing.one_liner')
48
+ const desc = val(L.description, 'listing.description')
49
+ push('## 3. Listing', '',
50
+ `- Server name${count(name, LIMITS.name)}: ${name}`,
51
+ `- One-liner${count(one, LIMITS.one_liner)}: ${one}`,
52
+ `- Categories: ${val(L.categories, 'listing.categories')}`,
53
+ `- Documentation URL: ${val(L.docs_url, 'listing.docs_url')}`,
54
+ `- Privacy policy URL: ${val(L.privacy_url, 'listing.privacy_url')}`,
55
+ `- Terms URL (if asked): ${val(L.terms_url, 'listing.terms_url')}`,
56
+ `- Support contact: ${val(L.support_email, 'listing.support_email')}`,
57
+ `- Icon URL: ${val(L.icon_url, 'listing.icon_url')}`,
58
+ '', `Description${count(desc, LIMITS.description)}:`, '', desc, '')
59
+
60
+ push('## 4. Use cases', '')
61
+ const ucs = (cfg.use_cases || []).filter(u => u && u.use_case)
62
+ if (!ucs.length) push(`- ${val(null, 'use_cases')}`)
63
+ ucs.forEach((u, i) => push(`- Use case ${i + 1}: ${u.use_case}`, ` - Example prompt ${i + 1}: ${val(u.prompt, `use_cases[${i}].prompt`)}`))
64
+ push('', `- Prerequisites: ${val(cfg.prerequisites, 'prerequisites')}`,
65
+ `- Read / write capabilities: ${ACCESS_LABEL[cfg.access] || val(null, 'access')}`, '')
66
+
67
+ push('## 5. Company', '',
68
+ `- Company name: ${val(C.name, 'company.name')}`,
69
+ `- Company website: ${val(C.website, 'company.website')}`,
70
+ `- Full name: ${val(C.contact_name, 'company.contact_name')}`,
71
+ `- Email: ${val(C.contact_email, 'company.contact_email')}`,
72
+ `- Role: ${val(C.role, 'company.role')}`,
73
+ '- Anthropic contact: leave empty unless you have one', '')
74
+
75
+ push('## 6. Authentication', '')
76
+ if (auth && auth.mode === 'oauth') {
77
+ const client = auth.registration.includes('Client ID Metadata Document') ? 'Client ID Metadata Document' : 'Dynamic Client Registration'
78
+ push('- Authentication requirement: Always required', '- Authentication methods: OAuth sign-in',
79
+ `- OAuth client: ${client} (the server supports: ${auth.registration.join(' and ') || 'none detected'})`,
80
+ '- Additional request headers: leave empty', '')
81
+ } else if (auth && auth.mode === 'none') {
82
+ push('- Authentication requirement: None', '')
83
+ } else {
84
+ push('Not detected: run `mcpship check` first.', '')
85
+ }
86
+
87
+ push('## 7. Data handling', '',
88
+ `- API ownership: ${OWN_LABEL[D.api_ownership] || val(null, 'data_handling.api_ownership')}`,
89
+ `- Personal health data: ${yesNo(D.health_data, 'data_handling.health_data')}`,
90
+ `- Sponsored or promoted content: ${yesNo(D.sponsored, 'data_handling.sponsored')}`)
91
+ if (D.notes) push('', 'Additional notes:', '', D.notes)
92
+ push('')
93
+
94
+ push('## 8. Test & launch', '', 'Test setup instructions:', '', '```',
95
+ val(cfg.reviewer && cfg.reviewer.instructions, 'reviewer.instructions'), '```', '',
96
+ '- Self-tested: tick it only after you have called every tool yourself, as a custom connector in Claude or with MCP Inspector.', '')
97
+
98
+ push('## 9. Compliance', '',
99
+ 'Tick all seven acknowledgements. They hold when `mcpship check` passes and:', '',
100
+ '- the server calls your own API, or a partner API you may proxy;',
101
+ '- it moves no money and generates no images, video or audio;',
102
+ '- no tool description gives instructions to the model (rule tools.instructions);',
103
+ '- it collects no conversation data beyond what its tools need;',
104
+ '- the documentation URL is live (rule listing.pages).', '')
105
+
106
+ push('## 10. Review and submit', '',
107
+ 'Read any quality warnings, then submit. Afterwards run `mcpship done directory`. Escalations: mcp-review@anthropic.com.', '')
108
+
109
+ return { text: out.join('\n'), missing }
110
+ }
111
+
112
+ function writePacket(dir, text) {
113
+ const file = path.join(dir, PACKET_PATH)
114
+ fs.mkdirSync(path.dirname(file), { recursive: true })
115
+ fs.writeFileSync(file, text)
116
+ return file
117
+ }
118
+
119
+ module.exports = { renderPacket, writePacket, PACKET_PATH }
package/lib/result.js ADDED
@@ -0,0 +1,13 @@
1
+ 'use strict'
2
+
3
+ function result(id, status, message, fix) {
4
+ return fix ? { id, status, message, fix } : { id, status, message }
5
+ }
6
+
7
+ function summarize(results) {
8
+ const s = { pass: 0, fail: 0, warn: 0, skip: 0 }
9
+ for (const r of results) s[r.status] += 1
10
+ return s
11
+ }
12
+
13
+ module.exports = { result, summarize }
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "mcpship",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Check a remote MCP server against the Claude directory rules and publish it: npm, MCP Registry, Claude Code plugin, Claude connector directory.",
5
+ "bin": { "mcpship": "bin/mcpship.js" },
6
+ "main": "lib/commands.js",
7
+ "files": ["bin/", "lib/", "README.md", "LICENSE"],
8
+ "scripts": { "test": "node --test test/*.test.js" },
9
+ "engines": { "node": ">=20" },
10
+ "keywords": ["mcp", "model-context-protocol", "claude", "connector", "plugin", "mcp-registry", "publish", "cli"],
11
+ "repository": { "type": "git", "url": "git+https://github.com/emanueltns/mcpship.git" },
12
+ "homepage": "https://github.com/emanueltns/mcpship",
13
+ "license": "MIT",
14
+ "author": "Freeapp SRL <contact@freeapp.ai>",
15
+ "mcpName": "io.github.emanueltns/mcpship"
16
+ }