niranzwp 0.1.0 → 0.6.1

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/lib/mcp.js ADDED
@@ -0,0 +1,185 @@
1
+ // Minimal MCP client over Streamable HTTP.
2
+ //
3
+ // Enough of the protocol to be useful and no more: initialize, tools/list,
4
+ // tools/call. Servers may answer with either JSON or an SSE stream, and may
5
+ // hand back a session id that every later request has to echo.
6
+ //
7
+ // This is what lets NiranzWP run abilities on sites whose OAuth tokens are
8
+ // scoped to an MCP endpoint rather than the core REST run route.
9
+
10
+ import { CliError } from './errors.js';
11
+
12
+ const PROTOCOL_VERSION = '2025-06-18';
13
+ const UA = 'niranzwp';
14
+
15
+ /** List MCP servers a WordPress site exposes. Unauthenticated. */
16
+ export async function listServers(siteUrl) {
17
+ const res = await fetch(`${siteUrl}/wp-json/mcp`, {
18
+ headers: { Accept: 'application/json', 'User-Agent': UA },
19
+ });
20
+ if (!res.ok) return [];
21
+
22
+ const body = await res.json().catch(() => null);
23
+ const routes = body?.routes ? Object.keys(body.routes) : [];
24
+
25
+ // "/mcp" itself is the index, not a server.
26
+ return routes
27
+ .filter((r) => r !== '/mcp' && r.startsWith('/mcp/'))
28
+ .map((r) => ({ route: r, url: `${siteUrl}/wp-json${r}`, name: r.slice('/mcp/'.length) }));
29
+ }
30
+
31
+ /**
32
+ * Pick the endpoint a bearer token should talk to. Servers that end in
33
+ * "-oauth" are the OAuth-protected variants; prefer those when we hold a
34
+ * token, and fall back to the plain one otherwise.
35
+ */
36
+ export function pickEndpoint(servers, { oauth = false } = {}) {
37
+ if (!servers.length) return null;
38
+ const oauthServer = servers.find((s) => s.name.endsWith('-oauth'));
39
+ const plain = servers.find((s) => !s.name.endsWith('-oauth'));
40
+ return (oauth ? oauthServer || plain : plain || oauthServer)?.url ?? servers[0].url;
41
+ }
42
+
43
+ export class McpClient {
44
+ constructor(endpoint, { token, basic, timeout = 30_000 } = {}) {
45
+ this.endpoint = endpoint;
46
+ this.token = token;
47
+ this.basic = basic;
48
+ this.timeout = timeout;
49
+ this.sessionId = null;
50
+ this.id = 0;
51
+ this.serverInfo = null;
52
+ }
53
+
54
+ headers() {
55
+ const h = {
56
+ 'Content-Type': 'application/json',
57
+ // Streamable HTTP lets the server answer with either.
58
+ Accept: 'application/json, text/event-stream',
59
+ 'User-Agent': UA,
60
+ 'MCP-Protocol-Version': PROTOCOL_VERSION,
61
+ };
62
+ if (this.token) h.Authorization = `Bearer ${this.token}`;
63
+ else if (this.basic) h.Authorization = `Basic ${this.basic}`;
64
+ if (this.sessionId) h['Mcp-Session-Id'] = this.sessionId;
65
+ return h;
66
+ }
67
+
68
+ async send(method, params, { notification = false } = {}) {
69
+ const payload = notification
70
+ ? { jsonrpc: '2.0', method, params }
71
+ : { jsonrpc: '2.0', id: ++this.id, method, params };
72
+
73
+ const controller = new AbortController();
74
+ const timer = setTimeout(() => controller.abort(), this.timeout);
75
+
76
+ let res;
77
+ try {
78
+ res = await fetch(this.endpoint, {
79
+ method: 'POST',
80
+ headers: this.headers(),
81
+ body: JSON.stringify(payload),
82
+ signal: controller.signal,
83
+ });
84
+ } catch (e) {
85
+ if ('AbortError' === e.name) {
86
+ throw new CliError('timeout', `MCP request timed out after ${this.timeout}ms.`);
87
+ }
88
+ throw new CliError('server_unreachable', `MCP request failed: ${e.message}`);
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+
93
+ // The session id is issued on initialize and must be echoed thereafter.
94
+ const sid = res.headers.get('mcp-session-id');
95
+ if (sid) this.sessionId = sid;
96
+
97
+ if (401 === res.status) {
98
+ throw new CliError('auth_required', 'The MCP endpoint rejected this credential.', {
99
+ hint: 'Run: niranzwp auth login <url>',
100
+ });
101
+ }
102
+ if (403 === res.status) {
103
+ throw new CliError('insufficient_scope', 'This credential is not accepted by the MCP endpoint.');
104
+ }
105
+
106
+ if (notification) return null;
107
+
108
+ const text = await res.text();
109
+ const msg = parseBody(text);
110
+
111
+ if (!msg) {
112
+ throw new CliError('server_unsupported', `MCP endpoint returned no JSON-RPC message (HTTP ${res.status}).`);
113
+ }
114
+ if (msg.error) {
115
+ throw new CliError('usage_error', `MCP error ${msg.error.code}: ${msg.error.message}`);
116
+ }
117
+ return msg.result;
118
+ }
119
+
120
+ async initialize() {
121
+ const result = await this.send('initialize', {
122
+ protocolVersion: PROTOCOL_VERSION,
123
+ capabilities: {},
124
+ clientInfo: { name: 'NiranzWP CLI', version: '0.6.0' },
125
+ });
126
+ this.serverInfo = result?.serverInfo ?? null;
127
+ // Per spec the server expects this acknowledgement before real calls.
128
+ await this.send('notifications/initialized', {}, { notification: true }).catch(() => {});
129
+ return result;
130
+ }
131
+
132
+ async listTools() {
133
+ const out = [];
134
+ let cursor;
135
+ do {
136
+ const r = await this.send('tools/list', cursor ? { cursor } : {});
137
+ out.push(...(r?.tools ?? []));
138
+ cursor = r?.nextCursor;
139
+ } while (cursor);
140
+ return out;
141
+ }
142
+
143
+ async callTool(name, args) {
144
+ const r = await this.send('tools/call', { name, arguments: args ?? {} });
145
+
146
+ // Tool failures come back as a normal result with isError set, not as a
147
+ // JSON-RPC error, so they have to be surfaced explicitly.
148
+ if (r?.isError) {
149
+ throw new CliError('usage_error', textOf(r) || `Tool "${name}" reported an error.`);
150
+ }
151
+ return r;
152
+ }
153
+ }
154
+
155
+ /** Accept a plain JSON body or an SSE stream carrying one message. */
156
+ function parseBody(text) {
157
+ if (!text) return null;
158
+
159
+ const trimmed = text.trimStart();
160
+ if (trimmed.startsWith('{')) {
161
+ try { return JSON.parse(trimmed); } catch { return null; }
162
+ }
163
+
164
+ // SSE: take the last complete data: payload.
165
+ let last = null;
166
+ for (const line of text.split('\n')) {
167
+ if (!line.startsWith('data:')) continue;
168
+ const chunk = line.slice(5).trim();
169
+ if (!chunk || '[DONE]' === chunk) continue;
170
+ try { last = JSON.parse(chunk); } catch { /* keep the previous one */ }
171
+ }
172
+ return last;
173
+ }
174
+
175
+ /** Flatten an MCP tool result into text, preferring structured content. */
176
+ export function textOf(result) {
177
+ if (!result) return '';
178
+ if (result.structuredContent !== undefined) {
179
+ return JSON.stringify(result.structuredContent, null, 2);
180
+ }
181
+ const parts = (result.content ?? [])
182
+ .map((c) => ('text' === c.type ? c.text : `[${c.type}]`))
183
+ .filter(Boolean);
184
+ return parts.join('\n');
185
+ }
package/lib/oauth.js ADDED
@@ -0,0 +1,196 @@
1
+ // OAuth client for WordPress sites that expose an authorization server.
2
+ //
3
+ // Implements the three specs a modern public client needs:
4
+ // RFC 7591 dynamic client registration -- no pre-shared client_id
5
+ // RFC 7636 PKCE with S256 -- mandatory, since there is no client secret
6
+ // RFC 8628 device authorization grant -- works over SSH and in containers
7
+ //
8
+ // Tokens rotate: every refresh returns a new refresh token and invalidates the
9
+ // old one, so the stored record is replaced atomically or not at all.
10
+
11
+ import { createHash, randomBytes } from 'node:crypto';
12
+ import { WpError } from './wp.js';
13
+
14
+ const UA = 'niranzwp';
15
+
16
+ // Refresh this many ms before the access token actually expires, so a request
17
+ // never races the expiry.
18
+ export const REFRESH_SKEW_MS = 60_000;
19
+
20
+ function b64url(buf) {
21
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
22
+ }
23
+
24
+ export function pkce() {
25
+ const verifier = b64url(randomBytes(32));
26
+ const challenge = b64url(createHash('sha256').update(verifier).digest());
27
+ return { verifier, challenge };
28
+ }
29
+
30
+ async function postForm(url, params, { timeout = 30_000 } = {}) {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), timeout);
33
+ try {
34
+ const res = await fetch(url, {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Content-Type': 'application/x-www-form-urlencoded',
38
+ Accept: 'application/json',
39
+ 'User-Agent': UA,
40
+ },
41
+ body: new URLSearchParams(params).toString(),
42
+ signal: controller.signal,
43
+ });
44
+ const text = await res.text();
45
+ let json = null;
46
+ try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON error body */ }
47
+ return { ok: res.ok, status: res.status, json, text };
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+ }
52
+
53
+ async function postJson(url, body, { timeout = 30_000 } = {}) {
54
+ const controller = new AbortController();
55
+ const timer = setTimeout(() => controller.abort(), timeout);
56
+ try {
57
+ const res = await fetch(url, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': UA },
60
+ body: JSON.stringify(body),
61
+ signal: controller.signal,
62
+ });
63
+ const text = await res.text();
64
+ let json = null;
65
+ try { json = text ? JSON.parse(text) : null; } catch { /* ignore */ }
66
+ return { ok: res.ok, status: res.status, json, text };
67
+ } finally {
68
+ clearTimeout(timer);
69
+ }
70
+ }
71
+
72
+ /** Fetch the authorization server metadata, or null if the site has none. */
73
+ export async function discover(siteUrl) {
74
+ try {
75
+ const res = await fetch(`${siteUrl}/.well-known/oauth-authorization-server`, {
76
+ headers: { Accept: 'application/json', 'User-Agent': UA },
77
+ });
78
+ if (!res.ok) return null;
79
+ const meta = await res.json();
80
+ return meta?.token_endpoint ? meta : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /** RFC 7591. Public client, so no secret is issued or expected. */
87
+ export async function registerClient(meta, { clientName = 'NiranzWP CLI' } = {}) {
88
+ if (!meta.registration_endpoint) {
89
+ throw new WpError('This site does not support dynamic client registration.');
90
+ }
91
+
92
+ const { ok, json, status, text } = await postJson(meta.registration_endpoint, {
93
+ client_name: clientName,
94
+ client_uri: 'https://niranz.dev',
95
+ grant_types: ['authorization_code', 'refresh_token', 'urn:ietf:params:oauth:grant-type:device_code'],
96
+ response_types: ['code'],
97
+ token_endpoint_auth_method: 'none',
98
+ redirect_uris: ['http://127.0.0.1/callback'],
99
+ scope: (meta.scopes_supported || ['mcp']).join(' '),
100
+ });
101
+
102
+ if (!ok || !json?.client_id) {
103
+ throw new WpError(`client registration failed (HTTP ${status}): ${json?.error_description || json?.error || text.slice(0, 200)}`);
104
+ }
105
+ return json.client_id;
106
+ }
107
+
108
+ /**
109
+ * RFC 8628. Returns the code the user types and the page they type it on.
110
+ * No local listener and no browser is needed on this machine.
111
+ */
112
+ export async function startDeviceFlow(meta, clientId, { scope } = {}) {
113
+ if (!meta.device_authorization_endpoint) {
114
+ throw new WpError('This site does not advertise the device authorization grant.');
115
+ }
116
+
117
+ const { ok, json, status, text } = await postForm(meta.device_authorization_endpoint, {
118
+ client_id: clientId,
119
+ scope: scope || (meta.scopes_supported || ['mcp']).join(' '),
120
+ });
121
+
122
+ if (!ok || !json?.device_code) {
123
+ throw new WpError(`device authorization failed (HTTP ${status}): ${json?.error_description || json?.error || text.slice(0, 200)}`);
124
+ }
125
+
126
+ return {
127
+ deviceCode: json.device_code,
128
+ userCode: json.user_code,
129
+ verificationUri: json.verification_uri_complete || json.verification_uri,
130
+ interval: Math.max(1, Number(json.interval || 5)),
131
+ expiresIn: Number(json.expires_in || 600),
132
+ };
133
+ }
134
+
135
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
136
+
137
+ /** Poll until the user approves, the code expires, or the deadline passes. */
138
+ export async function pollForToken(meta, clientId, device, { timeoutMs, onPending } = {}) {
139
+ const deadline = Date.now() + Math.min(device.expiresIn * 1000, timeoutMs ?? device.expiresIn * 1000);
140
+ let interval = device.interval * 1000;
141
+
142
+ while (Date.now() < deadline) {
143
+ await sleep(interval);
144
+
145
+ const { ok, json } = await postForm(meta.token_endpoint, {
146
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
147
+ device_code: device.deviceCode,
148
+ client_id: clientId,
149
+ });
150
+
151
+ if (ok && json?.access_token) return toRecord(json);
152
+
153
+ const err = json?.error;
154
+ if ('authorization_pending' === err) { onPending?.(); continue; }
155
+ // The server is asking us to back off; honour it rather than hammering.
156
+ if ('slow_down' === err) { interval += 5000; continue; }
157
+ if ('expired_token' === err) throw new WpError('The device code expired before it was approved.');
158
+ if ('access_denied' === err) throw new WpError('Authorization was denied.');
159
+ if (err) throw new WpError(`token request failed: ${json?.error_description || err}`);
160
+ }
161
+
162
+ throw new WpError('Timed out waiting for approval.');
163
+ }
164
+
165
+ function toRecord(json) {
166
+ return {
167
+ accessToken: json.access_token,
168
+ refreshToken: json.refresh_token || null,
169
+ scope: json.scope || null,
170
+ expiresAt: new Date(Date.now() + Number(json.expires_in || 3600) * 1000).toISOString(),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Exchange a refresh token for a new pair. The old refresh token is consumed,
176
+ * so the caller must persist the result before making any further request.
177
+ */
178
+ export async function refresh(meta, clientId, refreshToken) {
179
+ const { ok, json, status } = await postForm(meta.token_endpoint, {
180
+ grant_type: 'refresh_token',
181
+ refresh_token: refreshToken,
182
+ client_id: clientId,
183
+ });
184
+
185
+ if (!ok || !json?.access_token) {
186
+ throw new WpError(
187
+ `refresh failed (HTTP ${status}): ${json?.error_description || json?.error || 'unknown'}. Run: niranzwp auth login <url>`
188
+ );
189
+ }
190
+ return toRecord(json);
191
+ }
192
+
193
+ export function isExpired(record) {
194
+ if (!record?.expiresAt) return true;
195
+ return Date.parse(record.expiresAt) - REFRESH_SKEW_MS <= Date.now();
196
+ }
package/lib/schema.js ADDED
@@ -0,0 +1,94 @@
1
+ // A small JSON Schema validator -- only the keywords an ability input_schema
2
+ // actually uses. Catching a bad argument locally beats a round trip and a
3
+ // generic rest_invalid_param back from the site.
4
+
5
+ const typeOf = (v) => {
6
+ if (null === v) return 'null';
7
+ if (Array.isArray(v)) return 'array';
8
+ if (Number.isInteger(v)) return 'integer';
9
+ return typeof v;
10
+ };
11
+
12
+ function matchesType(value, expected) {
13
+ const actual = typeOf(value);
14
+ if (Array.isArray(expected)) return expected.some((t) => matchesType(value, t));
15
+ if ('number' === expected) return 'number' === actual || 'integer' === actual;
16
+ if ('integer' === expected) return 'integer' === actual;
17
+ return actual === expected;
18
+ }
19
+
20
+ /**
21
+ * @returns {string[]} human-readable errors, empty when the value is valid.
22
+ */
23
+ export function validate(value, schema, path = '') {
24
+ const errors = [];
25
+ if (!schema || 'object' !== typeof schema) return errors;
26
+
27
+ const at = path || 'input';
28
+
29
+ if (schema.type && !matchesType(value, schema.type)) {
30
+ errors.push(`${at} must be ${Array.isArray(schema.type) ? schema.type.join(' or ') : schema.type}, got ${typeOf(value)}`);
31
+ // A wrong type makes every nested check meaningless.
32
+ return errors;
33
+ }
34
+
35
+ if (schema.enum && !schema.enum.includes(value)) {
36
+ errors.push(`${at} must be one of: ${schema.enum.join(', ')}`);
37
+ }
38
+
39
+ if ('number' === typeOf(value) || 'integer' === typeOf(value)) {
40
+ if (schema.minimum !== undefined && value < schema.minimum) {
41
+ errors.push(`${at} must be >= ${schema.minimum}`);
42
+ }
43
+ if (schema.maximum !== undefined && value > schema.maximum) {
44
+ errors.push(`${at} must be <= ${schema.maximum}`);
45
+ }
46
+ }
47
+
48
+ if ('string' === typeOf(value)) {
49
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
50
+ errors.push(`${at} must be at least ${schema.minLength} characters`);
51
+ }
52
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
53
+ errors.push(`${at} must be at most ${schema.maxLength} characters`);
54
+ }
55
+ }
56
+
57
+ if ('array' === typeOf(value)) {
58
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
59
+ errors.push(`${at} must have at least ${schema.minItems} items`);
60
+ }
61
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) {
62
+ errors.push(`${at} must have at most ${schema.maxItems} items`);
63
+ }
64
+ if (schema.items) {
65
+ value.forEach((v, i) => errors.push(...validate(v, schema.items, `${at}[${i}]`)));
66
+ }
67
+ }
68
+
69
+ if ('object' === typeOf(value)) {
70
+ for (const key of schema.required ?? []) {
71
+ if (value[key] === undefined) errors.push(`${at}.${key} is required`);
72
+ }
73
+ for (const [key, sub] of Object.entries(schema.properties ?? {})) {
74
+ if (value[key] !== undefined) {
75
+ errors.push(...validate(value[key], sub, path ? `${path}.${key}` : key));
76
+ }
77
+ }
78
+ }
79
+
80
+ return errors;
81
+ }
82
+
83
+ /** Fill in defaults the schema declares, so callers can omit common fields. */
84
+ export function applyDefaults(value, schema) {
85
+ if (!schema?.properties || 'object' !== typeOf(value)) return value;
86
+
87
+ const out = { ...value };
88
+ for (const [key, sub] of Object.entries(schema.properties)) {
89
+ if (out[key] === undefined && sub.default !== undefined) {
90
+ out[key] = sub.default;
91
+ }
92
+ }
93
+ return out;
94
+ }
package/lib/store.js CHANGED
@@ -70,27 +70,65 @@ function account(name) {
70
70
  return `${SERVICE}:${name}`;
71
71
  }
72
72
 
73
+ function putSecret(name, value) {
74
+ if (useKeychain()) {
75
+ keychainSet(account(name), value);
76
+ return;
77
+ }
78
+ const s = fileSecrets();
79
+ s[name] = value;
80
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
81
+ writeFileSync(secretsPath(), JSON.stringify(s), { mode: 0o600 });
82
+ }
83
+
73
84
  export function saveProfile(name, { siteUrl, user, password }) {
74
85
  const all = readProfiles();
75
- all[name] = { siteUrl, user, createdAt: new Date().toISOString() };
86
+ all[name] = { siteUrl, user, auth: 'app-password', createdAt: new Date().toISOString() };
76
87
  writeProfiles(all);
88
+ putSecret(name, password);
89
+ }
77
90
 
78
- if (useKeychain()) {
79
- keychainSet(account(name), password);
80
- } else {
81
- const s = fileSecrets();
82
- s[name] = password;
83
- mkdirSync(DIR, { recursive: true, mode: 0o700 });
84
- writeFileSync(secretsPath(), JSON.stringify(s), { mode: 0o600 });
91
+ /**
92
+ * OAuth profiles keep the token pair in the same secret slot, serialized.
93
+ * Refresh rotation consumes the old refresh token, so this must be written
94
+ * before the new access token is used for anything.
95
+ */
96
+ export function saveOAuthProfile(name, { siteUrl, user, clientId, tokens }) {
97
+ const all = readProfiles();
98
+ all[name] = {
99
+ siteUrl,
100
+ user: user ?? null,
101
+ auth: 'oauth',
102
+ clientId,
103
+ expiresAt: tokens.expiresAt,
104
+ createdAt: all[name]?.createdAt ?? new Date().toISOString(),
105
+ };
106
+ writeProfiles(all);
107
+ putSecret(name, JSON.stringify(tokens));
108
+ }
109
+
110
+ /** Replace just the token pair after a refresh, keeping profile metadata. */
111
+ export function updateTokens(name, tokens) {
112
+ const all = readProfiles();
113
+ if (all[name]) {
114
+ all[name].expiresAt = tokens.expiresAt;
115
+ writeProfiles(all);
85
116
  }
117
+ putSecret(name, JSON.stringify(tokens));
86
118
  }
87
119
 
88
120
  export function getProfile(name) {
89
121
  const p = readProfiles()[name];
90
122
  if (!p) return null;
91
- const password = useKeychain() ? keychainGet(account(name)) : fileSecrets()[name];
92
- if (!password) return null;
93
- return { name, ...p, password };
123
+ const secret = useKeychain() ? keychainGet(account(name)) : fileSecrets()[name];
124
+ if (!secret) return null;
125
+
126
+ if ('oauth' === p.auth) {
127
+ let tokens = null;
128
+ try { tokens = JSON.parse(secret); } catch { return null; }
129
+ return { name, ...p, tokens };
130
+ }
131
+ return { name, ...p, password: secret };
94
132
  }
95
133
 
96
134
  export function listProfiles() {