Compare commits
5 Commits
0076fa0d37
...
feat/perso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1180bbdc1d | ||
|
|
709068ce68 | ||
|
|
c1f0117b80 | ||
| 0515f99e17 | |||
|
|
6fbd9c19fc |
@@ -167,12 +167,16 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update upstreams for each server
|
||||
// Add/update upstreams for each server. Always rebuild, even when an
|
||||
// upstream with this name already exists: McpdUpstream binds the caller's
|
||||
// bearer token (and the server id) at construction, and a cached project
|
||||
// router outlives both. Reusing the old object meant a token rotation left
|
||||
// every pre-rotation server silently 401-ing on tools/list — attached,
|
||||
// healthy, zero tools, no error — while servers attached after the rotation
|
||||
// worked, because only THEY got an upstream carrying the fresh token.
|
||||
for (const server of servers) {
|
||||
if (!currentNames.has(server.name)) {
|
||||
const upstream = new McpdUpstream(server.id, server.name, toolClient, server.description, discoveryClient);
|
||||
router.addUpstream(upstream);
|
||||
}
|
||||
const upstream = new McpdUpstream(server.id, server.name, toolClient, server.description, discoveryClient);
|
||||
router.addUpstream(upstream);
|
||||
// Applied on every sync, not just on first registration: raising a
|
||||
// server's timeout should take effect at the next refresh rather than
|
||||
// requiring the upstream to be dropped and rebuilt.
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface PluginContextDeps {
|
||||
cache: CacheProvider;
|
||||
log: StageLogger;
|
||||
discoverTools: () => Promise<ToolDefinition[]>;
|
||||
getToolDiscoveryFailures?: () => Array<{ server: string; message: string }>;
|
||||
routeToUpstream: (request: JsonRpcRequest) => Promise<JsonRpcResponse>;
|
||||
fetchPromptIndex: () => Promise<PromptIndexEntry[]>;
|
||||
getSystemPrompt: (name: string, fallback: string) => Promise<string>;
|
||||
@@ -96,6 +97,10 @@ export class PluginContextImpl implements PluginSessionContext {
|
||||
return this.deps.discoverTools();
|
||||
}
|
||||
|
||||
getToolDiscoveryFailures(): Array<{ server: string; message: string }> {
|
||||
return this.deps.getToolDiscoveryFailures?.() ?? [];
|
||||
}
|
||||
|
||||
routeToUpstream(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
||||
return this.deps.routeToUpstream(request);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface PluginSessionContext {
|
||||
|
||||
// Upstream access
|
||||
discoverTools(): Promise<ToolDefinition[]>;
|
||||
/** Servers that contributed no tools on the last discoverTools run, with the error. */
|
||||
getToolDiscoveryFailures(): Array<{ server: string; message: string }>;
|
||||
routeToUpstream(request: JsonRpcRequest): Promise<JsonRpcResponse>;
|
||||
|
||||
// Prompt access
|
||||
|
||||
@@ -127,6 +127,7 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
|
||||
parts.push(` ${sanitizeWireName(t.name)}`);
|
||||
}
|
||||
}
|
||||
parts.push(...formatDiscoveryFailures(ctx.getToolDiscoveryFailures()));
|
||||
} catch {
|
||||
// Tool discovery is optional
|
||||
}
|
||||
@@ -177,6 +178,22 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A partial catalog must never look like a healthy one. When attached servers
|
||||
* contributed no tools, say so in-band — the client has no other channel to
|
||||
* learn that six servers silently vanished from its tool list.
|
||||
*/
|
||||
function formatDiscoveryFailures(failures: Array<{ server: string; message: string }>): string[] {
|
||||
if (failures.length === 0) return [];
|
||||
const parts: string[] = [];
|
||||
parts.push(`\n⚠ WARNING: ${failures.length} attached server(s) contributed NO tools to this catalog:`);
|
||||
for (const f of failures) {
|
||||
parts.push(` - ${f.server}: ${f.message}`);
|
||||
}
|
||||
parts.push('The tool list above is INCOMPLETE. Report this to the user instead of treating a missing tool as nonexistent.');
|
||||
return parts;
|
||||
}
|
||||
|
||||
// ── begin_session tool definition ──
|
||||
|
||||
function getBeginSessionTool(llmSelector: LlmPromptSelector | null): ToolDefinition {
|
||||
@@ -442,6 +459,7 @@ async function handleBeginSession(
|
||||
responseParts.push(` ${sanitizeWireName(t.name)}`);
|
||||
}
|
||||
}
|
||||
responseParts.push(...formatDiscoveryFailures(ctx.getToolDiscoveryFailures()));
|
||||
} catch {
|
||||
// Tool discovery is optional
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ export class McpRouter {
|
||||
// `tools/list` from re-hitting slow/dead upstreams and absorbs negative results so one
|
||||
// dead server only stalls the first POST, not every subsequent one.
|
||||
private listCache = new Map<string, ListCacheEntry>();
|
||||
/** Per-server failures from the most recent discoverTools run. */
|
||||
private toolDiscoveryFailures: Array<{ server: string; message: string }> = [];
|
||||
private readonly LIST_CACHE_POSITIVE_TTL_MS = 30_000;
|
||||
private readonly LIST_CACHE_NEGATIVE_TTL_MS = 30_000;
|
||||
|
||||
@@ -176,6 +178,7 @@ export class McpRouter {
|
||||
error: (msg: string) => console.error(`[plugin:${sessionId.slice(0, 8)}] ${msg}`),
|
||||
},
|
||||
discoverTools: () => this.discoverTools(),
|
||||
getToolDiscoveryFailures: () => this.getToolDiscoveryFailures(),
|
||||
routeToUpstream: (request) => this.routeNamespacedCall(request, 'name', this.toolToServer),
|
||||
fetchPromptIndex: () => this.fetchPromptIndex(),
|
||||
getSystemPrompt: (name, fallback) => this.getSystemPrompt(name, fallback),
|
||||
@@ -329,7 +332,7 @@ export class McpRouter {
|
||||
const started = Date.now();
|
||||
let cachedCount = 0;
|
||||
let freshCount = 0;
|
||||
const failed: string[] = [];
|
||||
const failed: Array<{ server: string; message: string }> = [];
|
||||
|
||||
// Discover tools from all servers in parallel so one slow server doesn't block the rest
|
||||
const entries = [...this.upstreams.entries()];
|
||||
@@ -369,7 +372,7 @@ export class McpRouter {
|
||||
const cached = result.value.cached;
|
||||
if (cached.kind === 'err') {
|
||||
cachedCount++;
|
||||
failed.push(serverName);
|
||||
failed.push({ server: serverName, message: cached.message });
|
||||
continue;
|
||||
}
|
||||
response = { jsonrpc: '2.0', id: `cached-${serverName}`, result: cached.result };
|
||||
@@ -381,7 +384,7 @@ export class McpRouter {
|
||||
const message = (response.error as { message?: string }).message ?? 'unknown error';
|
||||
this.setListCacheEntry(serverName, 'tools/list', { kind: 'err', message, fetchedAt: Date.now() });
|
||||
console.warn(`[discoverTools] ${serverName}: ${message}`);
|
||||
failed.push(serverName);
|
||||
failed.push({ server: serverName, message });
|
||||
continue;
|
||||
}
|
||||
if (response.result !== undefined) {
|
||||
@@ -410,16 +413,27 @@ export class McpRouter {
|
||||
}
|
||||
}
|
||||
|
||||
this.toolDiscoveryFailures = failed;
|
||||
|
||||
if (entries.length > 0) {
|
||||
const elapsed = Date.now() - started;
|
||||
const project = this.projectName ? ` project=${this.projectName}` : '';
|
||||
const failedStr = failed.length > 0 ? ` failed=[${failed.join(',')}]` : '';
|
||||
const failedStr = failed.length > 0 ? ` failed=[${failed.map((f) => f.server).join(',')}]` : '';
|
||||
console.info(`[discoverTools]${project} fresh=${freshCount} cached=${cachedCount}${failedStr} elapsed=${elapsed}ms`);
|
||||
}
|
||||
|
||||
return allTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Servers that contributed no tools on the most recent `discoverTools` run,
|
||||
* with the error that silenced them. A partial catalog must never look like
|
||||
* a healthy one: the gate reads this to warn the client in-band.
|
||||
*/
|
||||
getToolDiscoveryFailures(): Array<{ server: string; message: string }> {
|
||||
return this.toolDiscoveryFailures;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover resources from all upstreams by calling resources/list on each.
|
||||
* Shares the per-server list cache with `discoverTools`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { refreshUpstreams } from '../src/discovery.js';
|
||||
import { refreshUpstreams, refreshProjectUpstreams } from '../src/discovery.js';
|
||||
import { McpRouter } from '../src/router.js';
|
||||
|
||||
function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) {
|
||||
@@ -69,3 +69,61 @@ describe('refreshUpstreams', () => {
|
||||
expect(router.getUpstreamNames()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshProjectUpstreams', () => {
|
||||
/**
|
||||
* Regression: a cached project router outlives the caller's bearer token.
|
||||
* Upstreams used to be created only for NEW server names, so after a token
|
||||
* rotation every pre-existing upstream kept posting the old token — six
|
||||
* healthy servers 401'd on tools/list forever while newly attached ones
|
||||
* worked. Every refresh must rebind existing upstreams to the fresh token.
|
||||
*/
|
||||
it('rebinds existing upstreams to the current auth token on every refresh', async () => {
|
||||
const router = new McpRouter();
|
||||
const servers = [{ id: 'srv-1', name: 'gitea', transport: 'stdio' }];
|
||||
|
||||
// Base client: withToken(t) mints a child client whose posts we can count.
|
||||
const children = new Map<string, ReturnType<typeof mockMcpdClient>>();
|
||||
const base = mockMcpdClient(servers);
|
||||
(base as any).forward = vi.fn(async () => ({ status: 200, body: servers }));
|
||||
(base as any).withToken = vi.fn((t: string) => {
|
||||
if (!children.has(t)) {
|
||||
const child = mockMcpdClient(servers);
|
||||
child.post = vi.fn(async () => ({ result: { tools: [{ name: 'merge_pull_request' }] } }));
|
||||
children.set(t, child);
|
||||
}
|
||||
return children.get(t);
|
||||
});
|
||||
|
||||
await refreshProjectUpstreams(router, base as any, 'sre', 'old-token');
|
||||
await refreshProjectUpstreams(router, base as any, 'sre', 'new-token');
|
||||
|
||||
const tools = await router.discoverTools();
|
||||
expect(tools.map((t) => t.name)).toEqual(['gitea/merge_pull_request']);
|
||||
// The proxy call must ride the NEW token's client, not the stale one.
|
||||
expect(children.get('new-token')!.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', expect.anything());
|
||||
expect(children.get('old-token')!.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates the server id when a server was recreated under the same name', async () => {
|
||||
const router = new McpRouter();
|
||||
const base = mockMcpdClient([]);
|
||||
const posts: unknown[] = [];
|
||||
const child = mockMcpdClient([]);
|
||||
child.post = vi.fn(async (_path: string, body: unknown) => {
|
||||
posts.push(body);
|
||||
return { result: { tools: [] } };
|
||||
});
|
||||
(base as any).withToken = vi.fn(() => child);
|
||||
(base as any).forward = vi.fn()
|
||||
.mockResolvedValueOnce({ status: 200, body: [{ id: 'srv-old', name: 'gitea', transport: 'stdio' }] })
|
||||
.mockResolvedValueOnce({ status: 200, body: [{ id: 'srv-new', name: 'gitea', transport: 'stdio' }] });
|
||||
|
||||
await refreshProjectUpstreams(router, base as any, 'sre', 't');
|
||||
await refreshProjectUpstreams(router, base as any, 'sre', 't');
|
||||
|
||||
await router.discoverTools();
|
||||
expect(posts).toHaveLength(1);
|
||||
expect((posts[0] as { serverId: string }).serverId).toBe('srv-new');
|
||||
});
|
||||
});
|
||||
|
||||
26
templates/dav.yaml
Normal file
26
templates/dav.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: dav
|
||||
version: "1.0.0"
|
||||
description: CalDAV/CardDAV/VTODO (Stalwart or any RFC 4791/6352 server) — calendars, events, tasks, contacts
|
||||
# NOT packageName: dav-mcp declares tsdav/tsdav-utils as GitHub-URL deps, so
|
||||
# `npx -y dav-mcp` dies with `spawn git ENOENT` on the git-less node-runner,
|
||||
# and the upstream ghcr image starts the HTTP server as PID 1. This image
|
||||
# bakes the install and execs the stdio entrypoint (NODE_OPTIONS baked in).
|
||||
dockerImage: "mysources.co.uk/michal/dav-mcp:4.0.1-stdio1"
|
||||
transport: STDIO
|
||||
repositoryUrl: https://mysources.co.uk/michal/dav-mcp-image
|
||||
healthCheck:
|
||||
# A real CalDAV PROPFIND: a pass proves server URL and credentials.
|
||||
tool: list_calendars
|
||||
arguments: {}
|
||||
intervalSeconds: 60
|
||||
timeoutSeconds: 10
|
||||
env:
|
||||
- name: CALDAV_SERVER_URL
|
||||
description: DAV base URL (e.g. http://stalwart.stalwart.svc.cluster.local:8080)
|
||||
required: true
|
||||
- name: CALDAV_USERNAME
|
||||
description: DAV username
|
||||
required: true
|
||||
- name: CALDAV_PASSWORD
|
||||
description: DAV password (use an app password)
|
||||
required: true
|
||||
45
templates/jmap.yaml
Normal file
45
templates/jmap.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
name: jmap
|
||||
version: "1.0.0"
|
||||
description: JMAP email (Stalwart) — search/read/organize + drafts; send and delete gated off by env
|
||||
dockerImage: "mysources.co.uk/michal/jmap-mcp:0.6.5-drafts3"
|
||||
transport: STDIO
|
||||
repositoryUrl: https://mysources.co.uk/michal/jmap-mcp
|
||||
# No command: the image's entrypoint IS the MCP server (deno run), mcpd
|
||||
# attaches to PID 1 stdin/stdout. Fork of wyattjoh/jmap-mcp adding
|
||||
# create_draft/create_draft_reply, JMAP_DISABLE_* gating, and Basic auth
|
||||
# (jmap-jam upstream only speaks Bearer).
|
||||
healthCheck:
|
||||
# A real Mailbox/get: a pass proves session URL, auth, and account
|
||||
# resolution, not just process liveness.
|
||||
tool: get_mailboxes
|
||||
arguments: {}
|
||||
intervalSeconds: 60
|
||||
timeoutSeconds: 10
|
||||
env:
|
||||
- name: JMAP_SESSION_URL
|
||||
description: JMAP session URL (e.g. http://stalwart.stalwart.svc.cluster.local:8080/.well-known/jmap)
|
||||
required: true
|
||||
- name: JMAP_BEARER_TOKEN
|
||||
description: Bearer token. Provide either this OR JMAP_USERNAME + JMAP_PASSWORD.
|
||||
required: false
|
||||
- name: JMAP_USERNAME
|
||||
description: Username for HTTP Basic auth (e.g. with a Stalwart app password)
|
||||
required: false
|
||||
- name: JMAP_PASSWORD
|
||||
description: Password/app password for HTTP Basic auth
|
||||
required: false
|
||||
- name: JMAP_ACCOUNT_ID
|
||||
description: Account ID (auto-detected when empty)
|
||||
required: false
|
||||
- name: JMAP_DISABLE_SUBMISSION
|
||||
description: "true = do not register send_email/reply_to_email (drafts only)"
|
||||
required: false
|
||||
defaultValue: "true"
|
||||
- name: JMAP_DISABLE_DELETE
|
||||
description: "true = do not register delete_emails (move to Trash still works)"
|
||||
required: false
|
||||
defaultValue: "true"
|
||||
- name: JMAP_REBASE_SESSION_URLS
|
||||
description: "true = rewrite the session's apiUrl/downloadUrl/uploadUrl/eventSourceUrl onto the session URL's origin. Required over a ClusterIP: Stalwart advertises its publicUrl and the hairpinned call 403s at Envoy."
|
||||
required: false
|
||||
defaultValue: "true"
|
||||
24
templates/karakeep.yaml
Normal file
24
templates/karakeep.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
name: karakeep
|
||||
version: "1.0.0"
|
||||
description: Karakeep bookmarks — search, read, create, lists, tags, highlights
|
||||
packageName: "@karakeep/mcp"
|
||||
runtime: node
|
||||
transport: STDIO
|
||||
repositoryUrl: https://github.com/karakeep-app/karakeep
|
||||
healthCheck:
|
||||
# A real API call (lists all lists): a pass proves address and API key.
|
||||
tool: get-lists
|
||||
arguments: {}
|
||||
intervalSeconds: 60
|
||||
timeoutSeconds: 10
|
||||
env:
|
||||
- name: KARAKEEP_API_ADDR
|
||||
description: Karakeep base URL (e.g. http://karakeep-web.karakeep.svc.cluster.local:3000)
|
||||
required: true
|
||||
- name: KARAKEEP_API_KEY
|
||||
description: API key created in the Karakeep UI (Settings → API Keys)
|
||||
required: true
|
||||
- name: NODE_OPTIONS
|
||||
description: V8 heap cap; Node sizes its heap off host RAM, not the cgroup limit
|
||||
required: false
|
||||
defaultValue: "--max-old-space-size=384"
|
||||
26
templates/ttrss.yaml
Normal file
26
templates/ttrss.yaml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: ttrss
|
||||
version: "1.0.0"
|
||||
description: Tiny Tiny RSS — feeds, headlines, article content, read/star state, subscribe
|
||||
dockerImage: "mysources.co.uk/michal/ttrss-mcp:1.0.0"
|
||||
transport: STDIO
|
||||
repositoryUrl: https://mysources.co.uk/michal/ttrss-mcp
|
||||
# No command: the image's entrypoint IS the MCP server. Custom server; the
|
||||
# public tt-rss MCPs only take a pre-made session id and die when it expires.
|
||||
# This one logs in with user/password and re-logs-in on NOT_LOGGED_IN.
|
||||
healthCheck:
|
||||
# A real getCategories call: a pass proves URL, login, and that the
|
||||
# user's "Enable API access" preference is on.
|
||||
tool: list_categories
|
||||
arguments: {}
|
||||
intervalSeconds: 60
|
||||
timeoutSeconds: 10
|
||||
env:
|
||||
- name: TTRSS_URL
|
||||
description: Full API endpoint (e.g. http://ttrss.ttrss.svc.cluster.local/api/)
|
||||
required: true
|
||||
- name: TTRSS_USER
|
||||
description: tt-rss user with "Enable API access" set in preferences
|
||||
required: true
|
||||
- name: TTRSS_PASSWORD
|
||||
description: That user's password
|
||||
required: true
|
||||
Reference in New Issue
Block a user