Skip to content

Commit 30188ff

Browse files
authored
chore: everything the registries and first-time visitors ask for (#13)
The official MCP Registry was unreachable: it proves ownership by fetching the published package and matching an `mcpName` field against the server name, and neither that field nor server.json existed. Both are here now, and server.json validates against the registry schema rather than against my reading of it. server.json repeats the version twice, which is the same problem the plugin manifests had. Both copies are placeholders and the release workflow stamps them from the tag alongside package.json, so there is still no version written down anywhere and still no release commit. A test pins both placeholders and checks that `mcpName`, the server name and the package identifier agree, since a mismatch only surfaces at publish time, after npm has already accepted the release. Publishing to the registry now hangs off the same tag as the npm publish, authenticated by the same OIDC token, so no secret is added. It retries: npm accepts a publish before the package is queryable, and the registry validates by fetching it. `--version` did not exist. It is the first thing a bug report needs, so the issue template asked for something that could not be produced. It answers before the configuration is read, which is the state a machine reporting a bug is usually in. SECURITY.md, CONTRIBUTING.md and an issue template, because people arriving from a directory land on a repository that can reconfigure their network and should be able to see how the password is stored and what happens to it. The issue template asks for model and firmware first: nearly every difference in this project comes down to one of those two. CONTRIBUTING says the thing that actually matters here rather than the usual formalities: verify against the router, not against your model of it, and name the model and version you checked on. It also answers the vendor-adapter question ahead of time, so nobody writes one before asking.
1 parent abb2487 commit 30188ff

11 files changed

Lines changed: 289 additions & 2 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Bug report
2+
description: Something behaves differently from what the documentation says
3+
labels: [bug]
4+
body:
5+
- type: markdown
6+
attributes:
7+
value: |
8+
Please do not paste real MAC addresses, private IP addresses, SSIDs or
9+
keys. Replace them with placeholders.
10+
11+
- type: input
12+
id: model
13+
attributes:
14+
label: Router model and KeeneticOS version
15+
description: >-
16+
From `get_system_info`, or the bottom of the router. Almost every
17+
difference in this project comes down to one of these two, so a report
18+
without them usually cannot be acted on.
19+
placeholder: Keenetic Ultra (KN-1811), KeeneticOS 5.1.3
20+
validations:
21+
required: true
22+
23+
- type: textarea
24+
id: what
25+
attributes:
26+
label: What happened
27+
description: >-
28+
The tool you called, what it returned, and what you expected instead.
29+
If a change reported success but did not take effect, say so plainly:
30+
the router accepts some wrong commands silently, and that is the failure
31+
this project cares about most.
32+
validations:
33+
required: true
34+
35+
- type: textarea
36+
id: reproduce
37+
attributes:
38+
label: How to reproduce
39+
placeholder: |
40+
1. list_devices { "filter": "active" }
41+
2. update_device { "mac": "02:00:00:00:00:01", "access": "deny" }
42+
3. The response says applied, the device stays online
43+
44+
- type: input
45+
id: version
46+
attributes:
47+
label: keenetic-mcp version
48+
description: "`npx keenetic-mcp --version`"
49+
placeholder: "0.3.0"

.github/ISSUE_TEMPLATE/config.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
blank_issues_enabled: true
2+
contact_links:
3+
- name: Design question, or another router vendor
4+
url: https://github.com/salatmaster/keenetic-mcp/issues/new
5+
about: >-
6+
Open a blank issue. Questions about where a boundary should sit, or
7+
whether a change would be taken, are welcome before any code is written.
8+
- name: Something with security implications
9+
url: https://github.com/salatmaster/keenetic-mcp/security/advisories/new
10+
about: Report privately rather than in a public issue.

.github/workflows/release.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ jobs:
4747
exit 1
4848
fi
4949
npm version "$version" --no-git-tag-version
50+
# server.json repeats the version twice and the MCP registry rejects a
51+
# value that disagrees with the published package, so it is stamped
52+
# from the same tag rather than kept in step by hand.
53+
jq --arg v "$version" '.version = $v | .packages[0].version = $v' \
54+
server.json > server.tmp && mv server.tmp server.json
5055
echo "publishing $(node -p 'require("./package.json").version')"
5156
5257
# Provenance ties the tarball to this workflow run, so anyone can verify it
@@ -56,3 +61,23 @@ jobs:
5661
# ENEEDAUTH rather than a clear message. Check that the publisher on npm
5762
# names this repository and this workflow filename exactly.
5863
- run: npm publish --provenance --access public
64+
65+
# The registry indexes metadata only, and proves ownership by fetching the
66+
# published package and matching its `mcpName` against the server name. So
67+
# it has to run after npm, and it retries: npm accepts the publish before
68+
# the package is queryable, and the first attempt can lose that race.
69+
#
70+
# Authenticated by the same OIDC token as the npm publish above, so there
71+
# is still no secret stored anywhere.
72+
- name: Publish to the MCP Registry
73+
run: |
74+
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" \
75+
| tar xz mcp-publisher
76+
./mcp-publisher login github-oidc
77+
for attempt in 1 2 3 4; do
78+
if ./mcp-publisher publish; then exit 0; fi
79+
echo "attempt $attempt failed, waiting for npm to catch up"
80+
sleep 20
81+
done
82+
echo "the package is on npm but the registry would not take it"
83+
exit 1

CONTRIBUTING.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Contributing
2+
3+
Bug reports and pull requests are welcome. Two external contributions have
4+
already landed, so this is not a formality.
5+
6+
## Running it
7+
8+
```
9+
npm install
10+
npm test # no router required
11+
npm run typecheck
12+
npm run build
13+
```
14+
15+
Tests run against sanitized fixtures captured from a real router. To refresh
16+
them, or to run a read-only smoke test against your own:
17+
18+
```
19+
KEENETIC_HOST=… KEENETIC_PASSWORD=… npm run capture:fixtures
20+
KEENETIC_TEST_HOST=… KEENETIC_TEST_PASSWORD=… npm run smoke
21+
```
22+
23+
## The one thing that matters most
24+
25+
**Verify against the router, not against your model of it.**
26+
27+
Keenetic answers a wrong field name with `{}`, HTTP 200, no error, and nothing
28+
changed. It is indistinguishable from success. Two real bugs here passed every
29+
unit test and only surfaced against live hardware, because the tests asserted
30+
what the author assumed the router does.
31+
32+
So: read every write back through whichever view actually exposes the field, and
33+
say in the pull request which model and KeeneticOS version you checked it on.
34+
`src/tools/write.ts` is the pattern. `docs/rci-api.md` collects the traps.
35+
36+
If a change cannot be verified without hardware you do not have, say so. That is
37+
a normal answer here and better than a confident guess.
38+
39+
## Never commit real network data
40+
41+
A test scans the whole repository for anything shaped like a real MAC address, a
42+
private IP or key material, and it will fail your build. It exists because those
43+
have leaked here before, including from a fixture captured off a live router.
44+
45+
Device names and SSIDs have no detectable shape, so nothing catches those.
46+
Read your own diff before pushing it.
47+
48+
## Style
49+
50+
Match the surrounding code. Comments explain why something is the way it is,
51+
especially when it looks wrong: most of them are load-bearing and record a trap
52+
that cost someone an afternoon.
53+
54+
Plain ASCII hyphens, no em dashes, anywhere.
55+
56+
Commit messages say what changed and why it had to change that way.
57+
58+
## Adding another vendor
59+
60+
Please open an issue first. The code is RCI-shaped throughout, and a vendor
61+
boundary with one implementation behind it would be a guess. A separate
62+
repository that borrows the tool shapes, the verified-write pattern and the
63+
skills format is the cheaper start, and the parts worth sharing can be extracted
64+
once there are two working servers to compare.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ different manifests but share the same skills and the same server definition.
163163
- **LAN only.** No cloud, no telemetry, no outbound connection to anything but
164164
your router.
165165

166+
Where the password is stored on each platform, and how to report something
167+
privately, are in [SECURITY.md](SECURITY.md).
168+
166169
## Supported routers
167170

168171
RCI, the API this uses, is a standard part of KeeneticOS rather than a feature

SECURITY.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Security
2+
3+
## What this software can reach
4+
5+
It authenticates to a router as an administrator and can change its
6+
configuration. That is the point of it, and it is also the whole risk: anything
7+
that can talk to this server can reconfigure the network it runs on.
8+
9+
## Where the password goes
10+
11+
The setup wizard stores the router password in the operating system keychain -
12+
Keychain Services on macOS, Credential Manager on Windows, Secret Service on
13+
Linux. If no keychain is available it falls back to a file with mode `0600` and
14+
says so rather than pretending otherwise.
15+
16+
The password is never written to a settings file, never included in a tool
17+
response, and never logged. `KEENETIC_HOST`, `KEENETIC_USER` and
18+
`KEENETIC_PASSWORD` override the stored values, which is what a container wants.
19+
20+
## What it does not do
21+
22+
No cloud, no telemetry, no outbound connection to anything but your router. It
23+
runs on your machine and speaks to the router over your LAN.
24+
25+
## Reducing what it can do
26+
27+
`--read-only` does not register the write tools at all, rather than registering
28+
them and refusing, so an agent never sees them.
29+
30+
Changes apply to the running configuration and are discarded on reboot until
31+
`save_config` is called, which nothing does on its own.
32+
33+
## Reporting something
34+
35+
Open a [security advisory](https://github.com/salatmaster/keenetic-mcp/security/advisories/new)
36+
rather than a public issue, and give the model and KeeneticOS version. Expect a
37+
first reply within a week.
38+
39+
Please do not include real MAC addresses, private IP addresses, SSIDs or keys in
40+
a report. A test in this repository scans every file for them precisely because
41+
they are easy to paste in by accident.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"name": "keenetic-mcp",
33
"version": "0.0.0-dev",
4+
"mcpName": "io.github.salatmaster/keenetic-mcp",
45
"description": "Control your Keenetic router from Claude, Codex, Cursor, or any MCP agent",
56
"type": "module",
67
"license": "MIT",

server.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3+
"name": "io.github.salatmaster/keenetic-mcp",
4+
"description": "Control a Keenetic router: devices, Wi-Fi, VPN routing, segments, safe verified writes.",
5+
"repository": {
6+
"url": "https://github.com/salatmaster/keenetic-mcp",
7+
"source": "github"
8+
},
9+
"version": "0.0.0-dev",
10+
"packages": [
11+
{
12+
"registryType": "npm",
13+
"identifier": "keenetic-mcp",
14+
"version": "0.0.0-dev",
15+
"transport": {
16+
"type": "stdio"
17+
},
18+
"environmentVariables": [
19+
{
20+
"name": "KEENETIC_HOST",
21+
"description": "Router address on the LAN, for example 192.168.1.1. Optional: `npx keenetic-mcp init` discovers it from the default gateway and stores it.",
22+
"isRequired": false,
23+
"isSecret": false,
24+
"format": "string"
25+
},
26+
{
27+
"name": "KEENETIC_USER",
28+
"description": "Router login. Optional, defaults to admin.",
29+
"isRequired": false,
30+
"isSecret": false,
31+
"format": "string"
32+
},
33+
{
34+
"name": "KEENETIC_PASSWORD",
35+
"description": "Router password. Optional: the setup wizard stores it in the operating system keychain instead. Use this in a container, where there is no keychain.",
36+
"isRequired": false,
37+
"isSecret": true,
38+
"format": "string"
39+
}
40+
]
41+
}
42+
]
43+
}

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ async function main(): Promise<void> {
3636
// credentials in .env instead of exporting them. A no-op once installed.
3737
loadLocalEnv();
3838

39+
// Before the config is read, so it answers on a machine that has never been
40+
// set up. Every bug report starts by asking which version is running.
41+
if (process.argv.includes('--version') || process.argv.includes('-v')) {
42+
process.stdout.write(`${resolveVersion()}\n`);
43+
return;
44+
}
45+
3946
// `init` is a subcommand rather than a second binary, so the published
4047
// surface stays a single command.
4148
if (process.argv[2] === 'init') {

tests/binary.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ interface RunResult {
2424
stderr: string;
2525
}
2626

27-
function run(entry: string, stdin: string, env: NodeJS.ProcessEnv): Promise<RunResult> {
27+
function run(
28+
entry: string,
29+
stdin: string,
30+
env: NodeJS.ProcessEnv,
31+
args: string[] = []
32+
): Promise<RunResult> {
2833
return new Promise(resolve => {
29-
const child = spawn(process.execPath, [entry], { env });
34+
const child = spawn(process.execPath, [entry, ...args], { env });
3035
let stdout = '';
3136
let stderr = '';
3237
child.stdout.on('data', chunk => {
@@ -102,6 +107,21 @@ describe('the built binary', () => {
102107
expect(response.result?.serverInfo?.version).toBe('9.9.9-probe');
103108
});
104109

110+
// Answered before the configuration is read, so it works on a machine that
111+
// has never run the wizard. That is the machine a bug report comes from.
112+
it('prints its version and exits, with no router configured', async () => {
113+
const result = await run(DIST, '', { KEENETIC_MCP_VERSION: '' }, ['--version']);
114+
115+
expect(result.code).toBe(0);
116+
expect(result.stdout.trim()).toBe(
117+
(
118+
JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as {
119+
version: string;
120+
}
121+
).version
122+
);
123+
});
124+
105125
// Regression: npm installs a bin as a symlink in node_modules/.bin, so under
106126
// npx the entry path is the link and the module path is its target. Version
107127
// 0.1.0 compared them unresolved, matched nothing, and exited silently.

0 commit comments

Comments
 (0)