Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ A resource server's `401` or `403 insufficient_scope` answer with its `WWW-Authe
_Avoid_: Auth probe, auth discovery result

**Authentication Flow**:
One shared attempt to make a Credential Identity usable: a silent token refresh when possible, otherwise browser authorization. Calls and `@refresh` join the same flow and wait for its outcome; an Authorization Challenge starts or joins it too.
One shared attempt to make a Credential Identity usable: a silent token refresh when possible, otherwise browser authorization. Calls, `@add`, and `@login` join the same flow and wait for its outcome; an Authorization Challenge starts or joins it too.
_Avoid_: Per-command login, token retry

**Caller Input**:
Expand All @@ -47,7 +47,7 @@ One caller-owned request that moves from accepted through queued or active work
_Avoid_: Detached job, background task

**Schema Cache**:
Rebuildable knowledge MCPX has observed about server tools and their schemas.
Rebuildable knowledge MCPX has observed about server tools and their schemas. A hint for agents, never the authority on what a call accepts; repaired on evidence of drift and swept weekly in the background (ADR 0011).
_Avoid_: Declared tools, registry schema

**Active State**:
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,18 @@ Focus on an internal mcpx command:
mcpx @schema '.["@add"]'
```

mcpx caches discovered tool schemas in the global registry. Cached schemas older
than one day are refreshed opportunistically in a single background worker when
mcpx starts, so normal commands do not block on schema discovery. Servers that
have not discovered any tools yet are still retried synchronously during startup.
mcpx caches discovered tool schemas so commands and `@schema` answer without
asking the server. The cache is a hint, not the authority, and repairs itself
([ADR 0011](docs/adr/0011-the-schema-cache-is-a-hint-kept-fresh-in-the-background.md)):

- calling a tool the cache does not know, or omitting a field the cached schema
requires, re-lists that server before the call is rejected;
- a server's Invalid Params rejection, a new session (for schemas over an hour
old) and `tools/list_changed` re-list it in the background;
- `mcpxd` re-lists every schema older than a week when it starts and daily
while it runs.

Background listings never open a browser; there is no manual refresh command.

## Control Commands

Expand All @@ -136,7 +144,6 @@ mcpx @add "{ name: '<server>', url: '<mcp-url>', bearer: 'env:<TOKEN_ENV>' }"
mcpx @add "{ name: '<server>', transport: 'stdio', command: '<command>', args: ['<arg>'] }"
mcpx @remove "{ name: '<server>' }"
mcpx @login "{ name: '<server>' }"
mcpx @refresh
mcpx @daemon.status
mcpx @daemon.stop
mcpx @daemon.server
Expand Down Expand Up @@ -168,12 +175,6 @@ mcpx @add '{
}'
```

`@refresh` checks registered MCP servers concurrently: it makes each server's
credentials usable, then refreshes its cached tool schemas. Progress for every
server (token refresh, browser authorization URL, tool listing, result) is
printed to stderr as it happens; stdout carries only the final report. A failing
server is reported by name and does not prevent the others from being checked.

Stdio servers are called through `mcpxd`, a user-local daemon that reuses stdio
MCP sessions across CLI invocations. `mcpxd` starts on demand, keeps idle stdio
servers warm, and can be inspected or stopped with `mcpx @daemon.status` and
Expand Down Expand Up @@ -228,7 +229,7 @@ OAuth credentials repair themselves during ordinary tool calls:

Providers that need a manual OAuth client can only prompt in an interactive
terminal; without one, the call fails immediately with `reauth-required` and
`mcpx @refresh` must be run in a terminal.
`mcpx @login "{ name: '<server>' }"` must be run in a terminal.

Tool calls use a 5 minute request timeout by default. Set
`MCPX_TOOL_CALL_TIMEOUT_MS` when a remote MCP server legitimately needs longer.
Expand Down
2 changes: 1 addition & 1 deletion docs/adr/0007-authentication-is-explicit.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
type: Decision
title: Authentication Is Explicit
status: superseded by 0008
status: superseded by 0008 and 0011
---

# Authentication Is Explicit
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
type: Decision
title: The Schema Cache Is a Hint, Kept Fresh in the Background
status: accepted
---

# The Schema Cache Is a Hint, Kept Fresh in the Background

MCP gives a client no cheap way to ask whether a server's tools changed: `tools/list` carries no version, ETag, or TTL, and `notifications/tools/list_changed` only reaches an open session. So mcpx does not try to learn the moment a schema changes. The Schema Cache is a hint for agents (`@schema`, generated commands); the server stays the authority on what a call accepts, and the cache is repaired wherever evidence of drift appears:

- **Unknown tool.** A CLI call to `<server>.<tool>` that the cache does not list re-syncs that server's schema before it is reported unknown.
- **Missing required field.** Input that misses a field the cached schema requires is judged again by the server's current schema before it is rejected.
- **Invalid Params.** A call the server rejects with JSON-RPC `-32602` (unknown tool, stale argument shape) re-lists the server in the background, at most once a minute.
- **Reconnect.** Every new session re-lists, in the background, a schema older than an hour.
- **`tools/list_changed`.** A notification during a call re-lists, as before.
- **Sweep.** mcpxd re-lists every schema older than a week when it starts and daily while it lives. mcpxd is the one MCP Runtime per user (ADR 0001) and each server has at most one listing in flight, so the sweep is exclusive without a lock file.

The first two are interactive: someone is waiting, so they may authorize like any Call (ADR 0008). Background listings never open a browser or ask for input; a credential that is unusable is recorded as `reauth-required` on the cached entry and left for the next real Call, and a failure keeps the last good tools.

This removes `@refresh`, the last remnant of [ADR 0007](0007-authentication-is-explicit.md): authorization already repairs itself during Calls (ADR 0008), signing in again is `@login` (ADR 0010), and schemas no longer need a manual trigger. `@add`, `@login`, and the CLI's re-sync share one internal `syncSchemas` Runtime operation (protocol 4).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mcpx",
"version": "0.15.0",
"version": "0.16.0",
"license": "MIT",
"bin": {
"mcpx": "./src/main.ts"
Expand Down
16 changes: 13 additions & 3 deletions src/daemon-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ let lastActivityAt = Date.now()
const SESSION_IDLE_MS = 15 * 60 * 1000
const DAEMON_IDLE_MS = 30 * 60 * 1000
const CLEANUP_INTERVAL_MS = 30 * 1000
// mcpxd is the one Runtime per user, so its sweep is the exclusive background
// schema upkeep (ADR 0011): once when it starts, then daily while it lives.
const SCHEMA_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000

export async function runDaemonServer(): Promise<void> {
await ensureDaemonDir()
Expand Down Expand Up @@ -163,9 +166,16 @@ async function cleanupIdleRuntime(server: net.Server): Promise<void> {
}

async function getRuntime(): Promise<McpRuntime> {
runtimePromise ??= openRuntimeStores(daemonDir()).then(
(stores) => new McpRuntime(stores),
)
runtimePromise ??= openRuntimeStores(daemonDir()).then((stores) => {
const runtime = new McpRuntime(stores)
const sweep = () => {
if (!stopping) void runtime.sweepSchemas().catch(() => {})
}
// After the operation that started the Runtime, not ahead of it.
setTimeout(sweep, 0).unref()
setInterval(sweep, SCHEMA_SWEEP_INTERVAL_MS).unref()
return runtime
})
return runtimePromise
}

Expand Down
29 changes: 26 additions & 3 deletions src/json-schema-standard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,33 @@ type StandardSchema = StandardSchemaV1<
> &
StandardJSONSchemaV1<Record<string, unknown>, Record<string, unknown>>

export function jsonSchemaToStandardSchema(schema: unknown): StandardSchema {
export type StandardSchemaOptions = {
/**
* Called when input misses a field the cached schema requires: returns the
* server's current schema to judge the input by instead, or `undefined` to
* keep the cached verdict. A stale cache must not reject a valid call.
*/
resync?: () => Promise<unknown>
}

export function jsonSchemaToStandardSchema(
schema: unknown,
options: StandardSchemaOptions = {},
): StandardSchema {
const inputSchema = normalizeInputSchema(schema)

return {
'~standard': {
version: 1,
vendor: 'mcpx',
validate: (value: unknown) => validateObjectInput(value, inputSchema),
validate: async (value: unknown) => {
const result = validateObjectInput(value, inputSchema)
if (!result.issues || !options.resync || !isObject(value)) return result
const current = await options.resync()
return current === undefined
? result
: validateObjectInput(value, normalizeInputSchema(current))
},
jsonSchema: {
input: () => inputSchema,
// argc derives CLI parameter help from output schemas; MCP tool ingress
Expand Down Expand Up @@ -57,7 +76,7 @@ function validateObjectInput(
value: unknown,
schema: JsonSchema,
): StandardSchemaV1.Result<Record<string, unknown>> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
if (!isObject(value)) {
return {
issues: [{ message: 'Input must be a JSON object.' }],
}
Expand All @@ -83,3 +102,7 @@ function validateObjectInput(
// every provider's JSON Schema dialect can be locally enforced yet.
return { value: input }
}

function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
4 changes: 2 additions & 2 deletions src/references/daemon.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ when a tool call fails with no useful CLI output.
thrashing or session-id rejection loop).
- Two registry entries with identical launch parameters appear as separate
sessions in status (key derivation may not match expectation).
- Tool schema in the project does not match the live MCP server's tools and
`mcpx @refresh` does not converge.
- Tool schema in the project does not match the live MCP server's tools even
after calling the tool (calls re-sync a stale schema).

In all other cases prefer filing an issue with the command, input, and any
relevant log excerpt at https://github.com/celados/mcpx/issues.
Expand Down
120 changes: 90 additions & 30 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,18 @@ export async function runMcpx(
cwd: string,
mainPath: string,
): Promise<void> {
const snapshot = (await requestRuntime(
{ requestId: crypto.randomUUID(), op: 'registrySnapshot' },
mainPath,
)) as RuntimeRegistrySnapshot
const registry: RegistryView = {
servers: snapshot.servers as unknown as RegistryConfig['servers'],
const schemas = new SchemaResync(mainPath)
let registry = await schemas.registry()
// A tool the cache has never seen may be one the server added since: ask
// the server before calling it unknown (ADR 0011).
const target = toolTarget(argv[0], registry)
if (target) {
const current = registry
// An unreachable server leaves the cached verdict standing.
registry = await schemas.resync(target.serverName).catch(() => current)
}

const app = cli(buildRouter(registry), {
const app = cli(buildRouter(registry, schemas), {
name: 'mcpx',
version: MCPX_VERSION,
description: 'Global MCP registry and agent-facing command surface.',
Expand All @@ -152,9 +155,72 @@ export async function runMcpx(
)
}

function buildRouter(registry: RegistryView): Router {
/**
* The CLI's one way back to the server when its cached schema looks stale: at
* most one `syncSchemas` per server per invocation.
*/
class SchemaResync {
readonly #mainPath: string
readonly #synced = new Map<string, Promise<RegistryView>>()

constructor(mainPath: string) {
this.#mainPath = mainPath
}

async registry(): Promise<RegistryView> {
const snapshot = (await requestRuntime(
{ requestId: crypto.randomUUID(), op: 'registrySnapshot' },
this.#mainPath,
)) as RuntimeRegistrySnapshot
return { servers: snapshot.servers as unknown as RegistryConfig['servers'] }
}

resync(serverName: string): Promise<RegistryView> {
let synced = this.#synced.get(serverName)
if (!synced) {
synced = requestRuntime(
{
requestId: crypto.randomUUID(),
op: 'syncSchemas',
serverNames: [serverName],
},
this.#mainPath,
{ onEvent: renderRuntimeProgress, onInput: promptForRuntimeInput },
).then(() => this.registry())
this.#synced.set(serverName, synced)
}
return synced
}

/** The server's current input schema for a tool, if it still has one. */
async inputSchema(serverName: string, toolName: string): Promise<unknown> {
const registry = await this.resync(serverName).catch(() => undefined)
const tool = registry?.servers[serverName]?.tools?.find(
(item) => item.name === toolName,
)
return tool ? (tool.inputSchema ?? {}) : undefined
}
}

function toolTarget(
arg: string | undefined,
registry: RegistryView,
): { serverName: string; commandName: string } | undefined {
if (!arg || arg.startsWith('@') || arg.startsWith('-')) return undefined
const dot = arg.indexOf('.')
if (dot <= 0) return undefined
const serverName = arg.slice(0, dot)
const commandName = arg.slice(dot + 1)
const server = registry.servers[serverName]
if (!server || !commandName) return undefined
if (server.tools?.some((tool) => tool.commandName === commandName))
return undefined
return { serverName, commandName }
}

function buildRouter(registry: RegistryView, schemas?: SchemaResync): Router {
return {
...buildServerRouter(registry),
...buildServerRouter(registry, schemas),
'@add': c
.meta({
description:
Expand Down Expand Up @@ -182,11 +248,6 @@ function buildRouter(registry: RegistryView): Router {
examples: [`mcpx @login "{ name: 'higgsfield' }"`],
})
.input(loginInput),
'@refresh': c.meta({
description:
'Refresh all registered MCP server schemas and report auth status.',
examples: ['mcpx @refresh'],
}),
'@daemon': group(
{ description: 'Inspect or stop the local mcpxd daemon.' },
{
Expand Down Expand Up @@ -218,7 +279,10 @@ function buildRouter(registry: RegistryView): Router {
}
}

function buildServerRouter(registry: RegistryView): Record<string, Router> {
function buildServerRouter(
registry: RegistryView,
schemas?: SchemaResync,
): Record<string, Router> {
const servers: Record<string, Router> = {}
for (const [serverName, server] of Object.entries(registry.servers)) {
const tools = server.tools ?? []
Expand All @@ -228,7 +292,16 @@ function buildServerRouter(registry: RegistryView): Record<string, Router> {
.meta({
description: describeTool(tool, serverName),
})
.input(jsonSchemaToStandardSchema(tool.inputSchema))
.input(
jsonSchemaToStandardSchema(
tool.inputSchema,
schemas
? {
resync: () => schemas.inputSchema(serverName, tool.name),
}
: {},
),
)
}
servers[serverName] = group(
{ description: describeServerTools(tools.length) },
Expand Down Expand Up @@ -357,7 +430,7 @@ function buildHandlers(
await requestRuntime(
{
requestId: crypto.randomUUID(),
op: 'refreshServers',
op: 'syncSchemas',
serverNames: [assertServerName(options.input.name)],
signIn: true,
},
Expand Down Expand Up @@ -388,19 +461,6 @@ function buildHandlers(
)
}

handlers['@refresh'] = async (
options: HandlerOptions<Record<string, never>>,
) => {
return await renderOutput(
await requestRuntime(
{ requestId: crypto.randomUUID(), op: 'refreshServers' },
mainPath,
{ onEvent: renderRuntimeProgress, onInput: promptForRuntimeInput },
),
options.context,
)
}

handlers['@daemon'] = {
status: async (options: HandlerOptions<Record<string, never>>) => {
return await renderOutput(
Expand Down
4 changes: 2 additions & 2 deletions src/runtime-authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ function authorizationFailure(serverName: string, error: unknown): Error {
if (error.code === 'timeout') {
return new RuntimeOperationError(
'timeout',
`Authorization for ${serverName} was not completed within 5 minutes. Run mcpx @refresh to retry.`,
`Authorization for ${serverName} was not completed within 5 minutes. Retry the command, or run mcpx @login.`,
)
}
if (error.code === 'operation-failed')
Expand Down Expand Up @@ -275,7 +275,7 @@ function parseManualClient(
if (typeof input.reason === 'string' && input.reason) {
throw reauthRequired(
serverName,
`OAuth client credentials must be entered interactively (${input.reason}). Run mcpx @refresh in a terminal`,
`OAuth client credentials must be entered interactively (${input.reason}). Run mcpx @login in a terminal`,
)
}
throw new RuntimeOperationError(
Expand Down
Loading
Loading