diff --git a/helius-mcp/src/tools/laserstream.ts b/helius-mcp/src/tools/laserstream.ts index b8741980..7668e4b2 100644 --- a/helius-mcp/src/tools/laserstream.ts +++ b/helius-mcp/src/tools/laserstream.ts @@ -100,7 +100,7 @@ export function registerLaserstreamTools(server: McpServer) { try { const network = getNetwork(); - const endpoint = getLaserstreamUrl(params.region as 'ewr' | 'pitt' | 'slc' | 'lax' | 'lon' | 'ams' | 'fra' | 'tyo' | 'sgp'); + const endpoint = getLaserstreamUrl(undefined, params.region as 'ewr' | 'pitt' | 'slc' | 'lax' | 'lon' | 'ams' | 'fra' | 'tyo' | 'sgp'); const sub: any = {}; if (params.subscribeSlots) sub.slots = { filterByCommitment: params.filterByCommitment }; diff --git a/helius-mcp/src/utils/helius.ts b/helius-mcp/src/utils/helius.ts index 94386c39..8ae4ec0e 100644 --- a/helius-mcp/src/utils/helius.ts +++ b/helius-mcp/src/utils/helius.ts @@ -2,8 +2,9 @@ import { createHelius, type HeliusClient } from 'helius-sdk'; import { MCP_USER_AGENT } from '../http.js'; import { getSharedApiKey } from './config.js'; import { wrapClientWithResilience, withResilience, READ_TIMEOUT_MS } from './resilience.js'; -import { registerSecret } from './redact.js'; +import { registerSecret, API_KEY_PLACEHOLDER } from './redact.js'; import { isSharedCredentialMode } from './runtime.js'; +import type { RequestContext } from './request-context.js'; let sessionApiKey: string | null = null; let sessionNetwork: 'mainnet-beta' | 'devnet' = 'mainnet-beta'; @@ -55,7 +56,20 @@ export function setApiKey(apiKey: string): void { heliusClient = null; // Reset client so it picks up new key } -export function getApiKey(): string { +export function getApiKey(ctx?: RequestContext): string { + // A supplied context answers alone. Falling through to the deployment's + // credential on an empty caller key would bill the operator for a request + // that failed to authenticate. + if (ctx) { + if (!ctx.apiKey) { + throw new Error('NO_API_KEY: the request carried a context with no Helius API key'); + } + // Deliberately not registered as a secret: the scrubber exists to keep the + // deployment's credential out of tool output, and this key is the caller's + // own. Registering it would scrub it from their own responses. + return ctx.apiKey; + } + const apiKey = sessionApiKey || process.env.HELIUS_API_KEY || getSharedApiKey(); if (!apiKey) { throw new Error('NO_API_KEY: Set HELIUS_API_KEY environment variable or use setHeliusApiKey tool'); @@ -65,16 +79,36 @@ export function getApiKey(): string { return apiKey; } -export function hasApiKey(): boolean { +export function hasApiKey(ctx?: RequestContext): boolean { + if (ctx) { + return !!ctx.apiKey; + } return !!(sessionApiKey || process.env.HELIUS_API_KEY || getSharedApiKey()); } -export function getHeliusClient(): HeliusClient { +export function getHeliusClient(ctx?: RequestContext): HeliusClient { + // A caller-supplied context gets its own client. Caching per key would be a + // cross-tenant hazard for no gain: construction measures ~0.012ms, far below + // the network call it wraps. + if (ctx) { + return wrapClientWithResilience( + createHelius({ + apiKey: getApiKey(ctx), + network: getNetwork(ctx) === 'devnet' ? 'devnet' : 'mainnet', + userAgent: MCP_USER_AGENT, + }), + ); + } + if (!heliusClient) { const apiKey = getApiKey(); // Wrap so idempotent reads get a timeout + retry-with-backoff; writes, // sends, and streaming pass through untouched. See utils/resilience.ts. - heliusClient = wrapClientWithResilience(createHelius({ apiKey, userAgent: MCP_USER_AGENT })); + heliusClient = wrapClientWithResilience(createHelius({ + apiKey, + network: getNetwork() === 'devnet' ? 'devnet' : 'mainnet', + userAgent: MCP_USER_AGENT, + })); } return heliusClient; } @@ -84,7 +118,11 @@ export function setNetwork(network: 'mainnet-beta' | 'devnet'): void { sessionNetwork = network; } -export function getNetwork(): 'mainnet-beta' | 'devnet' { +export function getNetwork(ctx?: RequestContext): 'mainnet-beta' | 'devnet' { + if (ctx?.network) { + return ctx.network; + } + const envNetwork = process.env.HELIUS_NETWORK; if (envNetwork === 'devnet' || envNetwork === 'mainnet-beta') { return envNetwork; @@ -92,26 +130,35 @@ export function getNetwork(): 'mainnet-beta' | 'devnet' { return sessionNetwork; } -export function getEnhancedWebSocketUrl(): string { - const network = getNetwork(); +export function getEnhancedWebSocketUrl(ctx?: RequestContext): string { + const network = getNetwork(ctx); const host = network === 'devnet' ? 'wss://atlas-devnet.helius-rpc.com' : 'wss://atlas-mainnet.helius-rpc.com'; - // Under a shared server-side credential this URL is tool output that reaches - // the caller, so it gets a placeholder the caller substitutes with their own - // key. Single-tenant stdio still returns a ready-to-use URL. + // Shared-credential mode and a per-caller context are contradictory states: + // the first says every caller shares the deployment's key, the second says + // this caller brought their own. The placeholder wins where they collide, + // because the alternative is worse — every response passes through + // `redactSecrets`, whose query-string rule rewrites *any* `api-key=` value in + // this mode, so returning the caller's key here yields `***REDACTED***` at the + // caller. An unusable URL that says what to substitute beats one that does + // not. The two settings become orthogonal when hosted mode is split out, and + // a bring-your-own-key deployment will run with redaction off. if (isSharedCredentialMode()) { - return `${host}/?api-key=YOUR_HELIUS_API_KEY`; + return `${host}/?api-key=${API_KEY_PLACEHOLDER}`; } - return `${host}/?api-key=${getApiKey()}`; + return `${host}/?api-key=${getApiKey(ctx)}`; } -export function getLaserstreamUrl(region?: 'ewr' | 'pitt' | 'slc' | 'lax' | 'lon' | 'ams' | 'fra' | 'tyo' | 'sgp'): string { +export function getLaserstreamUrl( + ctx?: RequestContext, + region?: 'ewr' | 'pitt' | 'slc' | 'lax' | 'lon' | 'ams' | 'fra' | 'tyo' | 'sgp', +): string { // Endpoint host is public; clients pass apiKey separately (e.g. @helius/laserstream subscribe options). // Do not call getApiKey() here or docs tools like getLaserstreamInfo fail unnecessarily. - const network = getNetwork(); + const network = getNetwork(ctx); if (network === 'devnet') { return `https://laserstream-devnet-ewr.helius-rpc.com`; } @@ -161,8 +208,12 @@ export async function loadSignerOrFail(): Promise<{ secretKey: Uint8Array; walle return { secretKey, walletAddress }; } -export async function restRequest(endpoint: string, options: RequestInit = {}): Promise { - const apiKey = getApiKey(); +export async function restRequest( + endpoint: string, + options: RequestInit = {}, + ctx?: RequestContext, +): Promise { + const apiKey = getApiKey(ctx); const separator = endpoint.includes('?') ? '&' : '?'; const url = `https://api.helius.xyz${endpoint}${separator}api-key=${apiKey}`; diff --git a/helius-mcp/src/utils/redact.ts b/helius-mcp/src/utils/redact.ts index f7149d8f..64aa3bed 100644 --- a/helius-mcp/src/utils/redact.ts +++ b/helius-mcp/src/utils/redact.ts @@ -12,10 +12,22 @@ import { isSharedCredentialMode } from './runtime.js'; */ const REDACTED = '***REDACTED***'; +/** Substitution instruction returned in shared-credential mode; see helius.ts. */ +export const API_KEY_PLACEHOLDER = 'YOUR_HELIUS_API_KEY'; /** Matches `api-key=` in a query string, however the URL is delimited. */ const API_KEY_QUERY_RE = /([?&]api-key=)[^&\s'"`)\]}<]+/gi; +/** + * Unbounded and process-global. One stdio caller registers one key, so this is + * fine today; a deployment serving many callers accumulates one entry per + * distinct key forever, and `redactSecrets` scans the whole set on every + * response. There is no cap, no TTL and no eviction. + * + * Making this request-local belongs with the removal of the other credential + * globals, not here — capping it in isolation would silently stop scrubbing an + * evicted key, which is worse than the growth. + */ const secrets = new Set(); /** @@ -39,7 +51,13 @@ export function redactSecrets(text: string): string { return text; } - let out = text.replace(API_KEY_QUERY_RE, `$1${REDACTED}`); + // The placeholder is instruction, not a leak. Rewriting it to ***REDACTED*** + // turns "substitute your own key here" into an opaque string, which is how + // the Enhanced WebSocket URL reached callers before this guard. + let out = text.replace(API_KEY_QUERY_RE, (match, prefix: string) => + match.endsWith(API_KEY_PLACEHOLDER) || match.endsWith(REDACTED) + ? match + : `${prefix}${REDACTED}`); for (const secret of secrets) { // split/join rather than RegExp so key contents need no escaping. out = out.split(secret).join(REDACTED); diff --git a/helius-mcp/src/utils/request-context.ts b/helius-mcp/src/utils/request-context.ts new file mode 100644 index 00000000..6bfa8fa0 --- /dev/null +++ b/helius-mcp/src/utils/request-context.ts @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto'; + +/** + * Per-request credential and network state. + * + * Everything the tool layer needs to act *as one caller*. The stdio build has a + * single caller for the life of the process, so it resolves this from module + * state; a hosted build has a different caller every request and resolves it + * from the request itself. + * + * This type is deliberately small: it carries what identifies and authorises a + * caller, not a grab-bag of request metadata. Anything that is the same for + * every caller — public docs, the action catalogue — stays shared. + * + * Lives in `utils/` rather than `router/` because `utils/helius.ts` consumes it + * and `utils/` never imports from `router/`. + */ +export type RequestContext = { + /** The caller's own Helius API key. Downstream calls bill this key. */ + apiKey: string; + /** Helius project the key belongs to, when the transport can resolve it. */ + projectId?: string; + /** + * Absent when the request did not state one. That is not the same as + * mainnet: a self-hosted server with `HELIUS_NETWORK=devnet` and a middleware + * that sets a token but no network must keep answering on devnet, so the + * resolver falls through to env and session rather than defaulting here. + */ + network?: 'mainnet-beta' | 'devnet'; + /** + * Opaque, stable per caller — not per request. + * + * It buckets `expandResult` handles. Per-request would break expansion + * immediately, since a handle is issued by one request and redeemed by the + * next; per-caller means a handle survives exactly as long as the caller does + * and is invisible to everyone else. + * + * Note this duplicates a responsibility `RouterContext` currently holds: + * `getRouterContext()` takes no argument and hands `dispatch.ts` a + * process-global session key. Nothing bridges the two yet, and nothing can + * until call sites start passing a context. The migration that converts them + * is where `getRouterContext` learns to derive from this field; until then + * this is the declared intent and `RouterContext` is the live implementation. + */ + sessionKey: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** Stable, non-reversible bucket id, so the raw key is not also a map key. */ +function deriveSessionKey(apiKey: string, projectId?: string): string { + return createHash('sha256') + .update(`${projectId ?? ''}:${apiKey}`) + .digest('hex') + .slice(0, 32); +} + +/** + * Build a context from the SDK's per-request `extra`, or `null` when the request + * carries no identity. + * + * `null` is the stdio case and is not an error: callers fall back to the module + * state that has always answered for them. A hosted entrypoint is expected to + * reject an unidentified request before dispatch ever reaches here. + */ +export function contextFromExtra(extra: unknown): RequestContext | null { + if (!isRecord(extra)) { + return null; + } + + const authInfo = extra.authInfo; + if (!isRecord(authInfo)) { + return null; + } + + // `extra.apiKey` first: `token` is the transport credential, and reading it + // as the Helius key couples the two. Once a hosted entrypoint fronts this + // with real OAuth, every access token would be appended as `?api-key=` to a + // Helius host. `token` stays as a fallback for a transport that authenticates + // with the Helius key itself, which is the shape we expect first. + const rawExtra = isRecord(authInfo.extra) ? authInfo.extra : undefined; + const fromExtra = typeof rawExtra?.apiKey === 'string' ? rawExtra.apiKey : ''; + const apiKey = fromExtra || (typeof authInfo.token === 'string' ? authInfo.token : ''); + if (!apiKey) { + return null; + } + + // Both of ours live under `extra`, which the SDK documents as the home for + // additional token data. Deliberately not `clientId`: that identifies the + // OAuth client application, and a Helius project is not one. + const authExtra = isRecord(authInfo.extra) ? authInfo.extra : undefined; + + const rawProjectId = authExtra?.projectId; + const projectId = typeof rawProjectId === 'string' && rawProjectId ? rawProjectId : undefined; + + // Unknown values are refused rather than coerced. `'Devnet'` or `'testnet'` + // silently routing a caller's writes to mainnet is not a safe default. + const rawNetwork = authExtra?.network; + let network: 'mainnet-beta' | 'devnet' | undefined; + if (rawNetwork !== undefined) { + if (rawNetwork !== 'devnet' && rawNetwork !== 'mainnet-beta') { + throw new Error( + `INVALID_NETWORK: request declared network "${String(rawNetwork)}"; ` + + 'expected "mainnet-beta" or "devnet".', + ); + } + network = rawNetwork; + } + + return { + apiKey, + projectId, + network, + sessionKey: deriveSessionKey(apiKey, projectId), + }; +} diff --git a/helius-mcp/tests/request-context.test.ts b/helius-mcp/tests/request-context.test.ts new file mode 100644 index 00000000..afefbe7b --- /dev/null +++ b/helius-mcp/tests/request-context.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { contextFromExtra, type RequestContext } from '../src/utils/request-context.js'; +import { getApiKey, hasApiKey, getNetwork, getEnhancedWebSocketUrl } from '../src/utils/helius.js'; + +const CALLER_A = 'aaaaaaaa-1111-2222-3333-444444444444'; +const CALLER_B = 'bbbbbbbb-5555-6666-7777-888888888888'; + +function extraWith(authInfo: unknown): unknown { + return { authInfo }; +} + +function ctx(overrides: Partial = {}): RequestContext { + return { + apiKey: CALLER_A, + network: 'mainnet-beta', + sessionKey: 'test-session', + ...overrides, + }; +} + +describe('contextFromExtra', () => { + it('returns null when the request carries no identity', () => { + // stdio: not an error, the caller falls back to module state + expect(contextFromExtra(undefined)).toBeNull(); + expect(contextFromExtra(null)).toBeNull(); + expect(contextFromExtra('nonsense')).toBeNull(); + expect(contextFromExtra({})).toBeNull(); + expect(contextFromExtra(extraWith(undefined))).toBeNull(); + }); + + it('returns null when authInfo carries no token', () => { + expect(contextFromExtra(extraWith({ extra: { projectId: 'proj_1' } }))).toBeNull(); + expect(contextFromExtra(extraWith({ token: '' }))).toBeNull(); + expect(contextFromExtra(extraWith({ token: 42 }))).toBeNull(); + }); + + it('reads the key from authInfo.token', () => { + expect(contextFromExtra(extraWith({ token: CALLER_A }))?.apiKey).toBe(CALLER_A); + }); + + it('reads the project from authInfo.extra', () => { + const built = contextFromExtra(extraWith({ token: CALLER_A, extra: { projectId: 'proj_1' } })); + expect(built?.projectId).toBe('proj_1'); + }); + + it('leaves the project undefined when it is absent or empty', () => { + expect(contextFromExtra(extraWith({ token: CALLER_A }))?.projectId).toBeUndefined(); + expect( + contextFromExtra(extraWith({ token: CALLER_A, extra: { projectId: '' } }))?.projectId, + ).toBeUndefined(); + }); + + it('ignores clientId, which identifies an OAuth client rather than a project', () => { + const built = contextFromExtra(extraWith({ token: CALLER_A, clientId: 'some-oauth-client' })); + expect(built?.projectId).toBeUndefined(); + }); + + it('reads the network from authInfo.extra', () => { + expect( + contextFromExtra(extraWith({ token: CALLER_A, extra: { network: 'devnet' } }))?.network, + ).toBe('devnet'); + }); + + it('leaves the network unset when the request does not state one', () => { + // Not the same as mainnet: a self-hosted server with HELIUS_NETWORK=devnet + // and a middleware that sets a token but no network must stay on devnet. + expect(contextFromExtra(extraWith({ token: CALLER_A }))?.network).toBeUndefined(); + }); + + it('refuses an unknown network rather than coercing it', () => { + // 'Devnet' or 'testnet' silently routing writes to mainnet is not a safe + // default, so this throws instead of guessing. + for (const bad of ['Devnet', 'testnet', 'mainnet', 42]) { + expect(() => + contextFromExtra(extraWith({ token: CALLER_A, extra: { network: bad } })), + ).toThrow(/INVALID_NETWORK/); + } + }); + + it('prefers extra.apiKey over the transport token', () => { + // `token` is the transport credential. Reading it as the Helius key couples + // the two, so it is only the fallback. + const built = contextFromExtra(extraWith({ + token: 'transport-bearer-token', + extra: { apiKey: CALLER_A }, + })); + expect(built?.apiKey).toBe(CALLER_A); + }); + + it('falls back to the token when no explicit key is given', () => { + expect(contextFromExtra(extraWith({ token: CALLER_A }))?.apiKey).toBe(CALLER_A); + }); +}); + +describe('sessionKey', () => { + it('is stable across requests from the same caller', () => { + // Handles are issued by one request and redeemed by the next, so a + // per-request key would break expansion immediately. + const first = contextFromExtra(extraWith({ token: CALLER_A, extra: { projectId: 'proj_1' } })); + const second = contextFromExtra(extraWith({ token: CALLER_A, extra: { projectId: 'proj_1' } })); + expect(first?.sessionKey).toBe(second?.sessionKey); + }); + + it('differs between callers', () => { + const a = contextFromExtra(extraWith({ token: CALLER_A })); + const b = contextFromExtra(extraWith({ token: CALLER_B })); + expect(a?.sessionKey).not.toBe(b?.sessionKey); + }); + + it('differs when the same key is presented under a different project', () => { + const a = contextFromExtra(extraWith({ token: CALLER_A, extra: { projectId: 'proj_1' } })); + const b = contextFromExtra(extraWith({ token: CALLER_A, extra: { projectId: 'proj_2' } })); + expect(a?.sessionKey).not.toBe(b?.sessionKey); + }); + + it('does not contain the raw key', () => { + const built = contextFromExtra(extraWith({ token: CALLER_A })); + expect(built?.sessionKey).not.toContain(CALLER_A); + expect(built?.sessionKey).toHaveLength(32); + }); +}); + +describe('resolvers with a context', () => { + const originalKey = process.env.HELIUS_API_KEY; + const originalNetwork = process.env.HELIUS_NETWORK; + + beforeEach(() => { + process.env.HELIUS_API_KEY = 'env-key-00000000'; + delete process.env.HELIUS_NETWORK; + }); + + afterEach(() => { + if (originalKey === undefined) delete process.env.HELIUS_API_KEY; + else process.env.HELIUS_API_KEY = originalKey; + if (originalNetwork === undefined) delete process.env.HELIUS_NETWORK; + else process.env.HELIUS_NETWORK = originalNetwork; + }); + + it('prefers the context key over the environment', () => { + expect(getApiKey(ctx())).toBe(CALLER_A); + }); + + it('falls back to the environment when no context is given', () => { + expect(getApiKey()).toBe('env-key-00000000'); + }); + + it('prefers the context network over the environment', () => { + process.env.HELIUS_NETWORK = 'mainnet-beta'; + expect(getNetwork(ctx({ network: 'devnet' }))).toBe('devnet'); + }); + + it('falls back to the environment when the context states no network', () => { + process.env.HELIUS_NETWORK = 'devnet'; + expect(getNetwork(ctx({ network: undefined }))).toBe('devnet'); + }); + + it('throws rather than billing the deployment for an empty context key', () => { + expect(() => getApiKey(ctx({ apiKey: '' }))).toThrow(/NO_API_KEY/); + expect(hasApiKey(ctx({ apiKey: '' }))).toBe(false); + }); + + it('keeps the existing env-then-session order when no context is given', () => { + process.env.HELIUS_NETWORK = 'devnet'; + expect(getNetwork()).toBe('devnet'); + }); + + it('reports a key as present when the context carries one', () => { + // Deliberately not asserting the no-context negative: the fallback chain + // ends at the on-disk shared config, so that result depends on the machine. + delete process.env.HELIUS_API_KEY; + expect(hasApiKey(ctx())).toBe(true); + }); + + it('placeholders the key under a shared credential, context or not', () => { + // The two settings are contradictory — one says every caller shares the + // deployment key, the other says this caller brought their own. The + // placeholder wins, because every response passes through redactSecrets, + // whose query rule rewrites any api-key value in this mode. + process.env.HELIUS_MCP_SHARED_CREDENTIAL = '1'; + try { + const url = getEnhancedWebSocketUrl(ctx({ network: 'devnet' })); + expect(url).toContain('YOUR_HELIUS_API_KEY'); + expect(url).not.toContain(CALLER_A); + } finally { + delete process.env.HELIUS_MCP_SHARED_CREDENTIAL; + } + }); + + it('still placeholders the key under a shared credential with no context', () => { + process.env.HELIUS_MCP_SHARED_CREDENTIAL = '1'; + try { + expect(getEnhancedWebSocketUrl()).toContain('YOUR_HELIUS_API_KEY'); + } finally { + delete process.env.HELIUS_MCP_SHARED_CREDENTIAL; + } + }); + + it('builds the WebSocket URL from the context, not module state', () => { + const url = getEnhancedWebSocketUrl(ctx({ network: 'devnet' })); + expect(url).toContain('atlas-devnet'); + expect(url).toContain(CALLER_A); + }); +});