outrank-cli 0.9.0 → 0.10.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/README.md +4 -0
- package/package.json +2 -1
- package/src/adapters/mcp/descriptions.js +8 -0
- package/src/adapters/mcp/input.js +7 -1
- package/src/adapters/mcp/instructions.js +13 -9
- package/src/adapters/mcp/remote.js +40 -0
- package/src/adapters/mcp/server.js +11 -3
- package/src/adapters/mcp/session.js +15 -8
- package/src/core/redact.js +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,10 @@ outrank-cli auth logout --profile globex # clears only
|
|
|
115
115
|
|
|
116
116
|
Requirements: Node.js 18+ (check with `node --version`), an Outrank API key from <https://www.outrank.so/dashboard/settings/api-keys>, and an organization on the current Outrank app version. The same guide is on the web at <https://www.outrank.so/docs/mcp>.
|
|
117
117
|
|
|
118
|
+
### Remote server
|
|
119
|
+
|
|
120
|
+
Outrank also hosts the same tools at `https://www.outrank.so/api/mcp` (Streamable HTTP), for clients that connect to a URL instead of starting a command, such as Claude, ChatGPT or Cursor. Sign in with OAuth when the client asks, or send an API key as `Authorization: Bearer outr_live_...`. The hosted server always uses the `auto` toolsets, has no read-only switch and takes images as base64 only: it never reads a file. It is built from `createRemoteMcpServer` in `src/adapters/mcp/remote.js`, which takes the key, the Outrank origin and, optionally, the organization's `auth whoami` envelope so it does not ask for it again.
|
|
121
|
+
|
|
118
122
|
### Claude Code
|
|
119
123
|
|
|
120
124
|
Run this in the project folder (macOS, Linux, WSL, or the Windows Command Prompt):
|
package/package.json
CHANGED
|
@@ -80,7 +80,15 @@ export const describeField = (spec, name, field) => {
|
|
|
80
80
|
return `${text}${defaultNote(field)}`;
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
const TITLE_WORD_SEPARATOR = /[.-]/;
|
|
84
|
+
|
|
85
|
+
export const toolTitle = (spec) => {
|
|
86
|
+
const text = spec.id.split(TITLE_WORD_SEPARATOR).join(' ');
|
|
87
|
+
return `${text.charAt(0).toUpperCase()}${text.slice(1)}`;
|
|
88
|
+
};
|
|
89
|
+
|
|
83
90
|
export const toolAnnotations = (spec) => ({
|
|
91
|
+
title: toolTitle(spec),
|
|
84
92
|
readOnlyHint: spec.kind === CommandKind.READ || spec.kind === CommandKind.LOCAL,
|
|
85
93
|
destructiveHint: spec.kind === CommandKind.DESTRUCTIVE || spec.kind === CommandKind.MONEY,
|
|
86
94
|
idempotentHint: spec.kind === CommandKind.READ || spec.kind === CommandKind.LOCAL,
|
|
@@ -17,6 +17,7 @@ const SCHEMA_DIALECT_KEY = '$schema';
|
|
|
17
17
|
const BROAD_UPLOAD_DIR_GUIDANCE =
|
|
18
18
|
'Ask the human to restart the server with --upload-dir <a directory that holds only the images to upload>, or ' +
|
|
19
19
|
'pass the image as base64 with a file name instead.';
|
|
20
|
+
const REMOTE_UPLOAD_GUIDANCE = 'Pass the image as base64 with a file name instead.';
|
|
20
21
|
const UPLOAD_DIR_GUIDANCE =
|
|
21
22
|
'Ask the human to create the upload directory or to restart the server with --upload-dir <existing directory>.';
|
|
22
23
|
|
|
@@ -39,7 +40,9 @@ const describeFilePath = (names, uploads) =>
|
|
|
39
40
|
const imageShape = (z, name, uploads) => {
|
|
40
41
|
const names = imageArgumentNames(name);
|
|
41
42
|
return {
|
|
42
|
-
|
|
43
|
+
...(uploads.dir === null
|
|
44
|
+
? {}
|
|
45
|
+
: { [names.filePath]: z.string().min(1).describe(describeFilePath(names, uploads)).optional() }),
|
|
43
46
|
[names.base64]: z
|
|
44
47
|
.string()
|
|
45
48
|
.min(1)
|
|
@@ -129,6 +132,9 @@ export const parseToolArguments = (toolInput, args) => {
|
|
|
129
132
|
};
|
|
130
133
|
|
|
131
134
|
const readImageFromPath = async (filePath, uploads) => {
|
|
135
|
+
if (uploads.dir === null) {
|
|
136
|
+
throw usageError('Uploads from a file path are not available on the remote server.', REMOTE_UPLOAD_GUIDANCE);
|
|
137
|
+
}
|
|
132
138
|
if (uploads.problem) {
|
|
133
139
|
throw usageError(
|
|
134
140
|
`Uploads from a file path are disabled because the upload directory is too broad (${uploads.problem}).`,
|
|
@@ -33,6 +33,7 @@ const WORKFLOW_RULES =
|
|
|
33
33
|
const SETUP_TOOLS_NOTE = ' Setup tools of every area stay listed.';
|
|
34
34
|
const REFRESH_NOTE = ' The tool list refreshes on its own once the organization lookup succeeds.';
|
|
35
35
|
const ENABLE_HINT = ' The human can change this with --toolsets <names|all> and a restart.';
|
|
36
|
+
const REMOTE_ENABLE_HINT = ' A toolset the organization gets later shows up after the client reconnects.';
|
|
36
37
|
const READ_ONLY_NOTE = ' The server runs read-only: only read tools are listed.';
|
|
37
38
|
const CONFIGURATION_REASON = 'left out by the server configuration';
|
|
38
39
|
const ENTITLEMENT_REASON = 'not available to this organization yet';
|
|
@@ -59,7 +60,7 @@ const FLOW_HINTS = Object.freeze({
|
|
|
59
60
|
marketplace_seller: () => `marketplace selling (${toolName('marketplace.seller.status')})`,
|
|
60
61
|
});
|
|
61
62
|
|
|
62
|
-
const describeOrganization = (session) => {
|
|
63
|
+
const describeOrganization = (session, remote) => {
|
|
63
64
|
const organization = session.organization();
|
|
64
65
|
const failure = session.failure();
|
|
65
66
|
if (failure) return `Context at startup: unavailable. ${failure.error.message}`;
|
|
@@ -67,8 +68,9 @@ const describeOrganization = (session) => {
|
|
|
67
68
|
session.identity().status === IdentityStatus.OK
|
|
68
69
|
? `organization ${organization.id ?? 'unknown'} (${organization.user_email ?? 'unknown user'})`
|
|
69
70
|
: 'organization not resolved yet';
|
|
71
|
+
const where = remote ? `remote server ${organization.base_url}` : `profile ${organization.profile}, base URL ${organization.base_url}`;
|
|
70
72
|
return (
|
|
71
|
-
`Context at startup: ${who},
|
|
73
|
+
`Context at startup: ${who}, ${where}. This server ` +
|
|
72
74
|
'works on one organization only; every result carries the current one under `organization`.'
|
|
73
75
|
);
|
|
74
76
|
};
|
|
@@ -84,15 +86,17 @@ const offReason = (session, selection) => {
|
|
|
84
86
|
return FALLBACK_TEXT[session.fallbackReason()] ?? FALLBACK_TEXT[FallbackReason.LOOKUP_PENDING];
|
|
85
87
|
};
|
|
86
88
|
|
|
87
|
-
const fallbackNote = (session, selection) => {
|
|
89
|
+
const fallbackNote = (session, selection, remote) => {
|
|
88
90
|
if (selection.source !== SelectionSource.FALLBACK) {
|
|
89
91
|
const narrowed = selection.disabledToolsets.length > 0 || toolsetsIn(selection, EntitlementMode.READ).length > 0;
|
|
90
|
-
|
|
92
|
+
if (!narrowed) return '';
|
|
93
|
+
return remote ? REMOTE_ENABLE_HINT : ENABLE_HINT;
|
|
91
94
|
}
|
|
95
|
+
if (remote) return REFRESH_NOTE;
|
|
92
96
|
return FALLBACK_NOTES[session.fallbackReason()] ?? REFRESH_NOTE;
|
|
93
97
|
};
|
|
94
98
|
|
|
95
|
-
const describeToolsets = (session) => {
|
|
99
|
+
const describeToolsets = (session, remote) => {
|
|
96
100
|
const selection = session.selection();
|
|
97
101
|
const full = toolsetsIn(selection, EntitlementMode.FULL);
|
|
98
102
|
const read = toolsetsIn(selection, EntitlementMode.READ);
|
|
@@ -103,7 +107,7 @@ const describeToolsets = (session) => {
|
|
|
103
107
|
read.length > 0 ? ` Read only: ${read.join(LIST_SEPARATOR)}.` : '',
|
|
104
108
|
off.length > 0 ? ` Off (${offReason(session, selection)}): ${off.join(LIST_SEPARATOR)}.` : '',
|
|
105
109
|
off.length > 0 && automatic ? SETUP_TOOLS_NOTE : '',
|
|
106
|
-
fallbackNote(session, selection),
|
|
110
|
+
fallbackNote(session, selection, remote),
|
|
107
111
|
selection.readOnly ? READ_ONLY_NOTE : '',
|
|
108
112
|
].join('');
|
|
109
113
|
};
|
|
@@ -123,15 +127,15 @@ const clip = (text) =>
|
|
|
123
127
|
? text
|
|
124
128
|
: `${text.slice(0, MAX_INSTRUCTIONS_CHARS - TRUNCATION_MARK.length)}${TRUNCATION_MARK}`;
|
|
125
129
|
|
|
126
|
-
export const buildInstructions = ({ session, version }) =>
|
|
130
|
+
export const buildInstructions = ({ session, version, remote = false }) =>
|
|
127
131
|
clip(
|
|
128
132
|
[
|
|
129
133
|
`Outrank MCP server (outrank-cli ${version}) for the Outrank SEO platform.`,
|
|
130
134
|
RESULT_RULES,
|
|
131
135
|
SAFETY_RULES,
|
|
132
136
|
WORKFLOW_RULES,
|
|
133
|
-
describeOrganization(session),
|
|
134
|
-
describeToolsets(session),
|
|
137
|
+
describeOrganization(session, remote),
|
|
138
|
+
describeToolsets(session, remote),
|
|
135
139
|
describeFlows(session),
|
|
136
140
|
]
|
|
137
141
|
.filter(Boolean)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { registry } from '../../commands/index.js';
|
|
2
|
+
import { ContextSource } from '../../core/context.js';
|
|
3
|
+
import { createLogger } from './logger.js';
|
|
4
|
+
import { createMcpServer } from './server.js';
|
|
5
|
+
import { parseToolsetOverride } from './toolsets.js';
|
|
6
|
+
|
|
7
|
+
export const REMOTE_PROFILE_NAME = 'remote';
|
|
8
|
+
|
|
9
|
+
const remoteOptions = () => ({
|
|
10
|
+
profile: undefined,
|
|
11
|
+
override: parseToolsetOverride(),
|
|
12
|
+
readOnly: false,
|
|
13
|
+
uploadDir: null,
|
|
14
|
+
uploadDirProblem: null,
|
|
15
|
+
startupError: null,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const remoteContext = ({ apiKey, baseUrl }) => ({
|
|
19
|
+
apiKey,
|
|
20
|
+
baseUrl: new URL(baseUrl).origin,
|
|
21
|
+
storedBaseUrl: undefined,
|
|
22
|
+
profile: { name: REMOTE_PROFILE_NAME, source: ContextSource.FLAG, explicit: true, known: true },
|
|
23
|
+
source: { apiKey: ContextSource.FLAG, baseUrl: ContextSource.FLAG, envIgnored: false, baseUrlEnvIgnored: false },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const createRemoteMcpServer = async ({ apiKey, baseUrl, version, whoami, timeouts, logger = createLogger() }) => {
|
|
27
|
+
if (typeof apiKey !== 'string' || apiKey === '') throw new TypeError('createRemoteMcpServer needs an apiKey.');
|
|
28
|
+
return createMcpServer({
|
|
29
|
+
registry,
|
|
30
|
+
version,
|
|
31
|
+
options: remoteOptions(),
|
|
32
|
+
env: {},
|
|
33
|
+
readConfig: async () => ({}),
|
|
34
|
+
context: remoteContext({ apiKey, baseUrl }),
|
|
35
|
+
whoami,
|
|
36
|
+
remote: true,
|
|
37
|
+
timeouts,
|
|
38
|
+
logger,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
PROGRESS_NOTIFICATION_METHOD,
|
|
24
24
|
WHOAMI_COMMAND_ID,
|
|
25
25
|
} from './constants.js';
|
|
26
|
-
import { describeField, describeTool, toolAnnotations } from './descriptions.js';
|
|
26
|
+
import { describeField, describeTool, toolAnnotations, toolTitle } from './descriptions.js';
|
|
27
27
|
import { createGuidanceRewriter } from './guidance.js';
|
|
28
28
|
import { MCP_LABELS, buildToolInput, parseToolArguments, toRawInput } from './input.js';
|
|
29
29
|
import { buildInstructions } from './instructions.js';
|
|
@@ -58,6 +58,7 @@ export const buildToolDefinition = (spec, uploads) => {
|
|
|
58
58
|
input,
|
|
59
59
|
tool: {
|
|
60
60
|
name: toolName(spec.id),
|
|
61
|
+
title: toolTitle(spec),
|
|
61
62
|
description: describeTool(spec),
|
|
62
63
|
inputSchema: input.inputSchema,
|
|
63
64
|
annotations: toolAnnotations(spec),
|
|
@@ -71,13 +72,18 @@ export const createMcpServer = async ({
|
|
|
71
72
|
options,
|
|
72
73
|
env,
|
|
73
74
|
readConfig,
|
|
75
|
+
context,
|
|
76
|
+
whoami,
|
|
77
|
+
remote = false,
|
|
74
78
|
timeouts = {},
|
|
75
79
|
logger = createLogger(),
|
|
76
80
|
throttle,
|
|
77
81
|
}) => {
|
|
78
82
|
const progressIntervalMs = timeouts.progressIntervalMs ?? MCP_PROGRESS_INTERVAL_MS;
|
|
79
83
|
const definitions = new Map();
|
|
80
|
-
const uploads =
|
|
84
|
+
const uploads = remote
|
|
85
|
+
? { dir: null, problem: null }
|
|
86
|
+
: { dir: options.uploadDir, problem: options.uploadDirProblem ?? null };
|
|
81
87
|
let server = null;
|
|
82
88
|
|
|
83
89
|
const definitionFor = (spec) => {
|
|
@@ -104,6 +110,8 @@ export const createMcpServer = async ({
|
|
|
104
110
|
options,
|
|
105
111
|
env,
|
|
106
112
|
readConfig,
|
|
113
|
+
context,
|
|
114
|
+
whoami,
|
|
107
115
|
timeouts,
|
|
108
116
|
onToolsChanged: notifyToolsChanged,
|
|
109
117
|
...(throttle ? { throttle } : {}),
|
|
@@ -199,7 +207,7 @@ export const createMcpServer = async ({
|
|
|
199
207
|
{ name: MCP_SERVER_NAME, version },
|
|
200
208
|
{
|
|
201
209
|
capabilities: { tools: { listChanged: true }, prompts: { listChanged: true }, resources: {} },
|
|
202
|
-
instructions: buildInstructions({ session, version }),
|
|
210
|
+
instructions: buildInstructions({ session, version, remote }),
|
|
203
211
|
},
|
|
204
212
|
);
|
|
205
213
|
server.onerror = (error) => {
|
|
@@ -80,6 +80,8 @@ export const createSession = ({
|
|
|
80
80
|
options,
|
|
81
81
|
env = process.env,
|
|
82
82
|
readConfig = loadConfig,
|
|
83
|
+
context: fixedContext = null,
|
|
84
|
+
whoami: initialWhoami = null,
|
|
83
85
|
timeouts = {},
|
|
84
86
|
now = Date.now,
|
|
85
87
|
onToolsChanged = () => {},
|
|
@@ -127,13 +129,15 @@ export const createSession = ({
|
|
|
127
129
|
const resolve = async () => {
|
|
128
130
|
const previousKey = state.resolved?.apiKey;
|
|
129
131
|
try {
|
|
130
|
-
state.resolved =
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
132
|
+
state.resolved =
|
|
133
|
+
fixedContext ??
|
|
134
|
+
resolveContext({
|
|
135
|
+
flags: { profile: options.profile },
|
|
136
|
+
env,
|
|
137
|
+
config: await readConfig(),
|
|
138
|
+
customBaseUrlFromStoredProfileOnly: true,
|
|
139
|
+
revealLocalDetails: false,
|
|
140
|
+
});
|
|
137
141
|
state.contextError = null;
|
|
138
142
|
} catch (error) {
|
|
139
143
|
state.resolved = null;
|
|
@@ -249,7 +253,10 @@ export const createSession = ({
|
|
|
249
253
|
const start = async () => {
|
|
250
254
|
if (!options.startupError) await resolve();
|
|
251
255
|
refreshSelection();
|
|
252
|
-
if (canIdentify())
|
|
256
|
+
if (initialWhoami && canIdentify()) applyWhoami(initialWhoami);
|
|
257
|
+
if (canIdentify() && state.identity.status !== IdentityStatus.OK) {
|
|
258
|
+
await withBudget(requestIdentity(), startupBudgetMs);
|
|
259
|
+
}
|
|
253
260
|
};
|
|
254
261
|
|
|
255
262
|
const ensureReady = async () => {
|
package/src/core/redact.js
CHANGED
|
@@ -3,7 +3,7 @@ import { SENSITIVE_BODY_FIELDS } from './constants.js';
|
|
|
3
3
|
export const REDACTED = '<redacted>';
|
|
4
4
|
const TOO_DEEP = '<nested too deeply>';
|
|
5
5
|
const DEFAULT_MAX_DEPTH = 32;
|
|
6
|
-
const API_KEY_PATTERN = /outr_(?:live|test)_[A-Za-z0-9_]+/g;
|
|
6
|
+
const API_KEY_PATTERN = /outr_(?:live|test|oat|ort)_[A-Za-z0-9_-]+/g;
|
|
7
7
|
const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9._~+/=-]+/g;
|
|
8
8
|
const SENSITIVE_FIELD_SET = new Set(SENSITIVE_BODY_FIELDS.map((name) => name.toLowerCase()));
|
|
9
9
|
const SENSITIVE_KEY_PATTERN = /token|secret|password|passwd|api[_-]?key|authorization|credential|private[_-]?key/i;
|