ebay-mcp 1.12.0 → 1.13.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 CHANGED
@@ -205,8 +205,21 @@ EBAY_MARKETPLACE_ID=EBAY_US # default marketplace (overridable per tool)
205
205
  EBAY_CONTENT_LANGUAGE=en-US # default request content language
206
206
  EBAY_USER_REFRESH_TOKEN=your_token # for higher rate limits
207
207
  EBAY_MCP_UI=on # interactive MCP Apps views (beta); "off" forces plain JSON
208
+ EBAY_MCP_TOOLS=all # tool exposure: "all", "dynamic", or a family list (see below)
208
209
  ```
209
210
 
211
+ ### Tool exposure (`EBAY_MCP_TOOLS`)
212
+
213
+ By default all tools are advertised to the agent at once. On a long conversation that catalogue is a meaningful slice of the context window, so two opt-in modes let you shrink it:
214
+
215
+ | Value | Behavior | Works on |
216
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
217
+ | `all` _(default, or unset)_ | Every tool advertised at startup. | every host |
218
+ | `dynamic` | Only three discovery tools are visible (`list_ebay_tools`, `enable_ebay_tools`, `disable_ebay_tools`). The agent searches the catalogue and loads only the tools it needs; they then appear natively. | hosts that honor `tools/listChanged` (e.g. Claude) |
219
+ | `inventory,fulfillment,…` | Registers **only** the named families (listed below), frozen for the session. | every host (incl. ChatGPT, Cursor) |
220
+
221
+ The family list is literal — you get exactly what you name. ChatGPT connectors need the `connector` family (its `search`/`fetch` tools); add it explicitly, e.g. `EBAY_MCP_TOOLS=connector,inventory`. An unknown family name fails fast at startup with the valid list. Valid families: `connector`, `token-management`, `account`, `inventory`, `fulfillment`, `marketing`, `analytics`, `metadata`, `taxonomy`, `communication`, `other`, `developer`, `trading`.
222
+
210
223
  ### Authentication & rate limits
211
224
 
212
225
  | Mode | Daily limit | Best for | Setup |
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { dirname, join } from 'path';
5
5
  import { LocaleEnum } from '../types/ebay-enums.js';
6
+ import { getToolGatingConfigError } from '../config/tool-families.js';
6
7
  import { getVersion } from '../utils/version.js';
7
8
  import { getErrorMessage } from '../utils/errors.js';
8
9
  // Get the current directory for loading scope files and .env
@@ -126,6 +127,11 @@ export function validateEnvironmentConfig() {
126
127
  if (!process.env.EBAY_REDIRECT_URI) {
127
128
  warnings.push('EBAY_REDIRECT_URI is not set. User OAuth flow will not work. Set this to enable user token generation.');
128
129
  }
130
+ // Validate EBAY_MCP_TOOLS (all | dynamic | comma-separated family list)
131
+ const toolGatingError = getToolGatingConfigError();
132
+ if (toolGatingError) {
133
+ errors.push(toolGatingError);
134
+ }
129
135
  // Validate that scope files exist
130
136
  try {
131
137
  getProductionScopes();
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Tool-family contract for the `EBAY_MCP_TOOLS` env var.
3
+ *
4
+ * This module is deliberately free of any import from the tool tree
5
+ * (`@/tools/*`). `config/environment.ts` validates `EBAY_MCP_TOOLS` and is itself
6
+ * imported by tool handlers (e.g. `tools/categories/token-management.ts`), so
7
+ * pulling the registry in here would create an import cycle. The heavier pieces
8
+ * that genuinely need the registry — the discovery catalogue and the meta-tools —
9
+ * live in `@/mcp/tool-gating.ts`, which imports these primitives.
10
+ *
11
+ * The family keys below must stay in lock-step with `toolCategories`; a unit test
12
+ * asserts the two agree so adding a category without updating this list fails CI.
13
+ */
14
+ /**
15
+ * Canonical tool-family keys, mirroring `toolCategories[].key`. These are the
16
+ * accepted tokens in a static `EBAY_MCP_TOOLS` list (e.g. `inventory,fulfillment`).
17
+ */
18
+ export const TOOL_FAMILY_KEYS = [
19
+ 'connector',
20
+ 'token-management',
21
+ 'account',
22
+ 'inventory',
23
+ 'fulfillment',
24
+ 'marketing',
25
+ 'analytics',
26
+ 'metadata',
27
+ 'taxonomy',
28
+ 'communication',
29
+ 'other',
30
+ 'developer',
31
+ 'trading',
32
+ ];
33
+ const FAMILY_KEY_SET = new Set(TOOL_FAMILY_KEYS);
34
+ /** Splits a raw `EBAY_MCP_TOOLS` list into trimmed, lowercased, non-empty tokens. */
35
+ function parseFamilyTokens(raw) {
36
+ return raw
37
+ .split(',')
38
+ .map((token) => token.trim().toLowerCase())
39
+ .filter(Boolean);
40
+ }
41
+ /**
42
+ * Resolves `EBAY_MCP_TOOLS` into a {@link ToolGatingMode}.
43
+ *
44
+ * Parsing is lenient by design — unknown family tokens are kept in the returned
45
+ * `static` mode so the dedicated validator ({@link getToolGatingConfigError}) can
46
+ * report them precisely. Callers that act on the mode should treat only
47
+ * {@link TOOL_FAMILY_KEYS} members as registerable.
48
+ */
49
+ export function resolveToolGatingMode(env = process.env) {
50
+ const raw = env.EBAY_MCP_TOOLS?.trim();
51
+ if (!raw || raw.toLowerCase() === 'all') {
52
+ return { kind: 'all' };
53
+ }
54
+ if (raw.toLowerCase() === 'dynamic') {
55
+ return { kind: 'dynamic' };
56
+ }
57
+ return { kind: 'static', families: parseFamilyTokens(raw) };
58
+ }
59
+ /**
60
+ * Validates `EBAY_MCP_TOOLS` and returns a human-readable error string, or
61
+ * `undefined` when the value is valid. Surfaced through
62
+ * `validateEnvironmentConfig` so a typo fails loudly at startup (server exits)
63
+ * rather than silently leaving the agent with fewer tools than intended.
64
+ */
65
+ export function getToolGatingConfigError(env = process.env) {
66
+ const mode = resolveToolGatingMode(env);
67
+ if (mode.kind !== 'static') {
68
+ return undefined;
69
+ }
70
+ if (mode.families.length === 0) {
71
+ return `EBAY_MCP_TOOLS is set but lists no tool families. Use "all", "dynamic", or a comma-separated list of: ${TOOL_FAMILY_KEYS.join(', ')}.`;
72
+ }
73
+ const unknown = mode.families.filter((family) => !FAMILY_KEY_SET.has(family));
74
+ if (unknown.length === 0) {
75
+ return undefined;
76
+ }
77
+ const noun = unknown.length === 1 ? 'family' : 'families';
78
+ return `EBAY_MCP_TOOLS contains unknown tool ${noun}: ${unknown.join(', ')}. Valid families: ${TOOL_FAMILY_KEYS.join(', ')}.`;
79
+ }
@@ -1,6 +1,8 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { EbaySellerApi } from '../api/index.js';
3
3
  import { getEbayConfig, mcpConfig } from '../config/environment.js';
4
+ import { resolveToolGatingMode } from '../config/tool-families.js';
5
+ import { createToolGatingController, DYNAMIC_MODE_INSTRUCTIONS, registerMetaTools, toolNamesInFamilies, } from '../mcp/tool-gating.js';
4
6
  import { buildUiToolResult, createUiBridge } from '../mcp/ui-bridge.js';
5
7
  import { getToolEntries } from '../tools/registry.js';
6
8
  import { getErrorMessage } from '../utils/errors.js';
@@ -56,18 +58,50 @@ function registerTool(server, api, entry, logToolExecution, ui) {
56
58
  }
57
59
  });
58
60
  ui.register(entry, registered);
61
+ return registered;
59
62
  }
60
63
  /**
61
64
  * Create an MCP server runtime and register all eBay tool handlers.
62
65
  */
63
66
  export function createEbayMcpRuntime(options = {}) {
64
67
  const api = options.api ?? new EbaySellerApi(getEbayConfig());
65
- const server = new McpServer(options.serverConfig ?? mcpConfig);
66
- const entries = getToolEntries();
68
+ const serverInfo = options.serverConfig ?? mcpConfig;
69
+ const mode = resolveToolGatingMode();
70
+ // Instructions are set only in dynamic mode so the agent knows the catalogue is
71
+ // hidden behind the discovery tools; default/static modes keep the handshake
72
+ // byte-for-byte unchanged (a bare single-arg construction).
73
+ const server = mode.kind === 'dynamic'
74
+ ? new McpServer(serverInfo, { instructions: DYNAMIC_MODE_INSTRUCTIONS })
75
+ : new McpServer(serverInfo);
67
76
  const ui = createUiBridge(server, import.meta.url);
68
- serverLogger.info(`Registering ${entries.length} tools`);
77
+ // Static mode registers only the named families; all and dynamic register the
78
+ // full catalogue (dynamic then disables it below, before the transport connects).
79
+ const allEntries = getToolEntries();
80
+ const entries = mode.kind === 'static'
81
+ ? (() => {
82
+ const names = toolNamesInFamilies(mode.families);
83
+ return allEntries.filter((entry) => names.has(entry.definition.name));
84
+ })()
85
+ : allEntries;
86
+ const handles = new Map();
69
87
  for (const entry of entries) {
70
- registerTool(server, api, entry, options.logToolExecution ?? false, ui);
88
+ handles.set(entry.definition.name, registerTool(server, api, entry, options.logToolExecution ?? false, ui));
89
+ }
90
+ if (mode.kind === 'dynamic') {
91
+ // Disable before `connect`: the SDK only emits `tools/listChanged` once the
92
+ // transport is connected, so these flips are silent. The agent re-enables the
93
+ // tools it needs via the meta-tools, which fire `listChanged` post-connect.
94
+ for (const handle of handles.values()) {
95
+ handle.disable();
96
+ }
97
+ registerMetaTools(server, createToolGatingController(handles));
98
+ serverLogger.info(`Dynamic tool mode: ${handles.size} eBay tools hidden behind 3 discovery tools`);
99
+ }
100
+ else if (mode.kind === 'static') {
101
+ serverLogger.info(`Static tool mode: registered ${handles.size} tools from families: ${mode.families.join(', ')}`);
102
+ }
103
+ else {
104
+ serverLogger.info(`Registering ${handles.size} tools`);
71
105
  }
72
106
  // Install after registration so every UI-eligible tool is captured before the
73
107
  // gate can flip their metadata on a UI-capable client's `initialize`.
@@ -0,0 +1,198 @@
1
+ import { z } from 'zod';
2
+ import { TOOL_FAMILY_KEYS } from '../config/tool-families.js';
3
+ import { toolCategories } from '../tools/categories/index.js';
4
+ /**
5
+ * Tool gating: the runtime half of the `EBAY_MCP_TOOLS=dynamic` feature.
6
+ *
7
+ * In dynamic mode the server registers every eBay tool but boots with them
8
+ * disabled, advertising only the three discovery meta-tools defined here. The
9
+ * agent browses the catalogue and enables just the tools it needs; the MCP SDK
10
+ * emits `tools/listChanged` automatically when a tool's `enabled` flag flips, so
11
+ * the host re-fetches and the freshly enabled tools appear natively, with their
12
+ * real input schemas and validation. This keeps advertised context proportional
13
+ * to what the agent actually uses instead of the full ~187-tool catalogue.
14
+ *
15
+ * The pure env parsing/validation lives in `@/config/tool-families.ts`; this
16
+ * module owns everything that needs the live tool registry.
17
+ */
18
+ /** Default page size for {@link ToolGatingController.list} when `limit` is omitted. */
19
+ const DEFAULT_LIST_LIMIT = 25;
20
+ /** Upper bound on a single discovery page, mirrored by the `list_ebay_tools` schema. */
21
+ const MAX_LIST_LIMIT = 100;
22
+ /**
23
+ * Initialize-time guidance shown to the agent in dynamic mode so it knows the
24
+ * catalogue is hidden behind the discovery tools. Set only when dynamic mode is
25
+ * active; the default and static modes leave the handshake untouched.
26
+ */
27
+ export const DYNAMIC_MODE_INSTRUCTIONS = 'This eBay server keeps its full tool catalogue hidden to save context. Only the discovery tools are visible right now. ' +
28
+ 'Call `list_ebay_tools` (no arguments) to see the tool families, then `list_ebay_tools` with a `family` or `query` to find specific tools, ' +
29
+ 'then `enable_ebay_tools` with the tool names you need — they will appear as normal tools you can call. ' +
30
+ 'Call `disable_ebay_tools` to remove tools you no longer need and reclaim context.';
31
+ /** Condenses a tool description to a single sentence for the discovery listing. */
32
+ function toSummary(description) {
33
+ const firstLine = description.split('\n', 1)[0].trim();
34
+ const sentenceEnd = firstLine.indexOf('. ');
35
+ const sentence = sentenceEnd === -1 ? firstLine : firstLine.slice(0, sentenceEnd + 1);
36
+ return sentence.length > 160 ? `${sentence.slice(0, 157)}...` : sentence;
37
+ }
38
+ /** Builds the discovery catalogue (tool rows, family overview, name→family index). */
39
+ function buildCatalog() {
40
+ const rows = [];
41
+ const families = [];
42
+ const familyByToolName = new Map();
43
+ for (const category of toolCategories) {
44
+ families.push({ key: category.key, title: category.title, count: category.entries.length });
45
+ for (const entry of category.entries) {
46
+ const { name, description } = entry.definition;
47
+ rows.push({ name, family: category.key, summary: toSummary(description) });
48
+ familyByToolName.set(name, category.key);
49
+ }
50
+ }
51
+ return { rows, families, familyByToolName };
52
+ }
53
+ const CATALOG = buildCatalog();
54
+ /**
55
+ * Resolves the tool names belonging to the given families, preserving the caller's
56
+ * ability to filter registry entries for static mode. Unknown family keys
57
+ * contribute nothing (they are rejected earlier by env validation).
58
+ */
59
+ export function toolNamesInFamilies(families) {
60
+ const wanted = new Set(families);
61
+ const names = new Set();
62
+ for (const [name, family] of CATALOG.familyByToolName) {
63
+ if (wanted.has(family)) {
64
+ names.add(name);
65
+ }
66
+ }
67
+ return names;
68
+ }
69
+ /** Parses an opaque list cursor (a stringified offset) back into a non-negative offset. */
70
+ function parseCursor(cursor) {
71
+ if (!cursor) {
72
+ return 0;
73
+ }
74
+ const offset = Number.parseInt(cursor, 10);
75
+ return Number.isInteger(offset) && offset > 0 ? offset : 0;
76
+ }
77
+ /**
78
+ * Creates the controller backing the discovery meta-tools.
79
+ *
80
+ * @param handles - Map of eBay tool name → its registered handle. Only these tools
81
+ * are gateable; the meta-tools themselves are intentionally absent so they can
82
+ * never be disabled and are excluded from `activeCount`.
83
+ */
84
+ export function createToolGatingController(handles) {
85
+ const activeCount = () => [...handles.values()].filter((handle) => handle.enabled).length;
86
+ const mutate = (names, enable) => {
87
+ const changed = [];
88
+ const unknown = [];
89
+ for (const name of names) {
90
+ const handle = handles.get(name);
91
+ if (!handle) {
92
+ unknown.push(name);
93
+ continue;
94
+ }
95
+ if (handle.enabled !== enable) {
96
+ if (enable) {
97
+ handle.enable();
98
+ }
99
+ else {
100
+ handle.disable();
101
+ }
102
+ }
103
+ changed.push(name);
104
+ }
105
+ return enable
106
+ ? { enabled: changed, unknown, activeCount: activeCount() }
107
+ : { disabled: changed, unknown, activeCount: activeCount() };
108
+ };
109
+ return {
110
+ list({ family, query, cursor, limit }) {
111
+ if (!family && !query) {
112
+ return {
113
+ families: CATALOG.families.map((row) => ({
114
+ ...row,
115
+ enabledCount: [...handles.entries()].filter(([name, handle]) => CATALOG.familyByToolName.get(name) === row.key && handle.enabled).length,
116
+ })),
117
+ hint: 'Call list_ebay_tools with { family } or { query } to list tools, then enable_ebay_tools.',
118
+ };
119
+ }
120
+ if (family && !CATALOG.families.some((row) => row.key === family)) {
121
+ return {
122
+ error: `Unknown tool family "${family}".`,
123
+ validFamilies: TOOL_FAMILY_KEYS,
124
+ };
125
+ }
126
+ const needle = query?.toLowerCase();
127
+ const matches = CATALOG.rows.filter((row) => {
128
+ if (family && row.family !== family) {
129
+ return false;
130
+ }
131
+ if (needle) {
132
+ return (row.name.toLowerCase().includes(needle) ||
133
+ row.summary.toLowerCase().includes(needle) ||
134
+ row.family.includes(needle));
135
+ }
136
+ return true;
137
+ });
138
+ const offset = parseCursor(cursor);
139
+ const size = Math.min(limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
140
+ const page = matches.slice(offset, offset + size);
141
+ const nextOffset = offset + size;
142
+ return {
143
+ tools: page.map((row) => ({ ...row, enabled: handles.get(row.name)?.enabled ?? false })),
144
+ total: matches.length,
145
+ nextCursor: nextOffset < matches.length ? String(nextOffset) : undefined,
146
+ };
147
+ },
148
+ enable: (names) => mutate(names, true),
149
+ disable: (names) => mutate(names, false),
150
+ };
151
+ }
152
+ /** Wraps controller output as a standard text tool result. */
153
+ function toToolResult(data) {
154
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
155
+ }
156
+ /**
157
+ * Registers the three discovery meta-tools on the server. Called only in dynamic
158
+ * mode, after the eBay tools are registered and disabled, so the controller sees
159
+ * every gateable handle.
160
+ */
161
+ export function registerMetaTools(server, controller) {
162
+ server.registerTool('list_ebay_tools', {
163
+ description: "Discover eBay tools without loading them into context. Call with no arguments to list the tool families; pass `family` to list a family's tools, or `query` to keyword-search across all tools. Returns lightweight rows (name, family, summary) — enable a tool with enable_ebay_tools to get its full schema and call it.",
164
+ inputSchema: {
165
+ family: z
166
+ .string()
167
+ .optional()
168
+ .describe('Restrict results to one family key (e.g. "inventory").'),
169
+ query: z
170
+ .string()
171
+ .optional()
172
+ .describe('Case-insensitive keyword matched against tool name, summary, and family.'),
173
+ cursor: z.string().optional().describe('Pagination cursor from a prior call.'),
174
+ limit: z
175
+ .number()
176
+ .int()
177
+ .positive()
178
+ .max(MAX_LIST_LIMIT)
179
+ .optional()
180
+ .describe(`Max rows per page (default ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT}).`),
181
+ },
182
+ }, (args) => toToolResult(controller.list(args)));
183
+ server.registerTool('enable_ebay_tools', {
184
+ description: 'Load specific eBay tools into context so they can be called. Pass the exact tool names from list_ebay_tools. Returns the names enabled, any unknown names, and the active tool count.',
185
+ inputSchema: {
186
+ names: z
187
+ .array(z.string())
188
+ .min(1)
189
+ .describe('Exact tool names to enable, as returned by list_ebay_tools.'),
190
+ },
191
+ }, ({ names }) => toToolResult(controller.enable(names)));
192
+ server.registerTool('disable_ebay_tools', {
193
+ description: 'Unload eBay tools previously enabled, reclaiming their context. Pass the exact tool names. Returns the names disabled, any unknown names, and the active tool count.',
194
+ inputSchema: {
195
+ names: z.array(z.string()).min(1).describe('Exact tool names to disable.'),
196
+ },
197
+ }, ({ names }) => toToolResult(controller.disable(names)));
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ebay-mcp",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Model Context Protocol (MCP) server that exposes 100% of eBay's Sell APIs (322 tools across 270 endpoints) to AI assistants like Claude, Cursor, and Cline, covering inventory, order fulfillment, marketing, analytics, and developer tools with OAuth authentication and refresh-token support.",
5
5
  "type": "module",
6
6
  "main": "build/index.js",