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
52 changes: 49 additions & 3 deletions src/mcp/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ import { createWebPlugin, type WebPlugin } from './plugins/web'
import { createPdfPlugin, type PdfPlugin } from './plugins/pdf'
import { createDesktopPlugin, type DesktopPlugin } from './plugins/desktop'
import { createMetaPlugin } from './plugins/meta'
import {
createMemoryPlugin,
detectMemoryBackend,
describeMemoryBackend,
MemoryBackendConfigError,
} from '../plugins/memory'
import type { BrowserSession, DesktopSession, PdfSession } from './types'
import type { Page } from '../index'

Expand All @@ -41,16 +47,32 @@ export interface DispatcherState {
}

/**
* Build the default first-party plugin set (web + pdf + desktop + meta)
* Build the default first-party plugin set (web + pdf + desktop + memory + meta)
* and return a `DispatcherState` that exposes both the new dispatcher
* and the legacy per-capability maps.
*
* Memory plugin notes:
* - Always included so AI tools have a stable `agentmark_memory_*` surface.
* - Backend chosen by `detectMemoryBackend()` — ActivepiecesMemoryBackend
* when THINKFLEET_* env vars are present (memory syncs to SaaS),
* LocalFileMemoryBackend otherwise (memory stays on disk).
* - Partial / malformed creds disable the memory plugin and log to
* stderr rather than crashing the whole MCP server. MCP clients
* (Claude Code, Cursor, …) surface those stderr lines so users see
* the misconfiguration.
*/
export function createDispatcherState(): DispatcherState {
const web: WebPlugin = createWebPlugin()
const pdf: PdfPlugin = createPdfPlugin()
const desktop: DesktopPlugin = createDesktopPlugin()
const meta = createMetaPlugin([web, pdf, desktop])
const plugins: AgentMarkPlugin[] = [web, pdf, desktop, meta]

const memory = tryBuildMemoryPlugin()

const featurePlugins: AgentMarkPlugin[] = [web, pdf, desktop]
if (memory) featurePlugins.push(memory)

const meta = createMetaPlugin(featurePlugins)
const plugins: AgentMarkPlugin[] = [...featurePlugins, meta]
const dispatcher = new Dispatcher(plugins)

return {
Expand All @@ -63,6 +85,30 @@ export function createDispatcherState(): DispatcherState {
}
}

/**
* Construct the memory plugin with an env-detected backend. Returns
* `null` when the env is misconfigured — keeps the rest of the MCP
* server alive but disables memory tools until the user fixes the
* config.
*/
function tryBuildMemoryPlugin(): AgentMarkPlugin | null {
try {
const backend = detectMemoryBackend()
// eslint-disable-next-line no-console
console.error(
`[agentmark] memory backend: ${describeMemoryBackend(backend)}`,
)
return createMemoryPlugin({ backend })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
const tag = err instanceof MemoryBackendConfigError ? 'config' : 'init'
// eslint-disable-next-line no-console
console.error(`[agentmark] memory plugin disabled (${tag}): ${message}`)
return null
}
}

/**
* Route a tool invocation through the legacy state's dispatcher.
* Equivalent to `state.dispatcher.dispatch(name, args)`.
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ export type {
// the live ThinkFleet agent-memory API).
export {
createMemoryPlugin,
detectMemoryBackend,
describeMemoryBackend,
MemoryBackendConfigError,
LocalFileMemoryBackend,
ActivepiecesMemoryBackend,
ActivepiecesMemoryError,
Expand Down
111 changes: 111 additions & 0 deletions src/plugins/memory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* right place. agentmark_memory_get can walk the scope hierarchy in
* one call.
*/
import { ActivepiecesMemoryBackend } from './activepieces-backend'
import { LocalFileMemoryBackend } from './store'
import { MEMORY_TOOLS } from './tool-defs'
import type { MemoryBackend } from './backend'
Expand Down Expand Up @@ -167,6 +168,116 @@ function optionalStringArray(v: unknown): string[] | undefined {
return v as string[]
}

/* ─── Env-aware backend detection ─────────────────────────────────────
*
* Lets the MCP server pick the right MemoryBackend automatically based
* on what's in the environment, without forcing every caller to know
* the construction recipe.
*
* Cascading rule:
* - All three of THINKFLEET_BASE_URL + THINKFLEET_PROJECT_ID +
* THINKFLEET_API_KEY present → ActivepiecesMemoryBackend (writes go
* to the SaaS hierarchical memory; available across machines + AI
* tools).
* - None present → LocalFileMemoryBackend (on-disk, offline-safe,
* same default the plugin has shipped with since v0.6).
* - Some-but-not-all present → throw. Refusing to silently fall back
* to local matters: a typo in one variable shouldn't quietly demote
* a user from "memory syncs to my team" to "memory only on my disk."
* Misconfiguration should be loud.
*
* Security note: the API key only ever lives in process env. Callers
* (e.g. ThinkFleet Desktop) should pass it from an OS keychain, not
* persist it in a plain settings file. The MCP install CLI's `--env`
* flag (β) is the supported transport into a client's MCP config.
*/

const ENV_BASE_URL = 'THINKFLEET_BASE_URL'
const ENV_PROJECT_ID = 'THINKFLEET_PROJECT_ID'
const ENV_API_KEY = 'THINKFLEET_API_KEY'
const ENV_CHATBOT_ID = 'THINKFLEET_CHATBOT_ID'

export class MemoryBackendConfigError extends Error {
constructor(message: string) {
super(message)
this.name = 'MemoryBackendConfigError'
}
}

/**
* Select the right `MemoryBackend` for the current environment.
*
* @param env Defaults to `process.env`. Pass a literal object in tests.
* @returns `ActivepiecesMemoryBackend` when full THINKFLEET creds are
* present, `LocalFileMemoryBackend` when none are.
* @throws {@link MemoryBackendConfigError} when partial creds or a
* malformed API key are detected.
*/
export function detectMemoryBackend(
env: NodeJS.ProcessEnv = process.env,
): MemoryBackend {
const baseUrl = (env[ENV_BASE_URL] ?? '').trim()
const projectId = (env[ENV_PROJECT_ID] ?? '').trim()
const apiKey = (env[ENV_API_KEY] ?? '').trim()
const chatbotId = (env[ENV_CHATBOT_ID] ?? '').trim() || undefined

const present: string[] = []
const missing: string[] = []
for (const [name, value] of [
[ENV_BASE_URL, baseUrl],
[ENV_PROJECT_ID, projectId],
[ENV_API_KEY, apiKey],
] as const) {
if (value.length > 0) present.push(name)
else missing.push(name)
}

if (present.length === 0) {
return new LocalFileMemoryBackend()
}

if (missing.length > 0) {
throw new MemoryBackendConfigError(
`Partial ThinkFleet credentials detected — found [${present.join(', ')}], `
+ `missing [${missing.join(', ')}]. Set all three of ${ENV_BASE_URL}, `
+ `${ENV_PROJECT_ID}, ${ENV_API_KEY} to use the SaaS memory backend, `
+ 'or unset all three to use the local file backend.',
)
}

if (!apiKey.startsWith('sk-')) {
throw new MemoryBackendConfigError(
`${ENV_API_KEY} must start with "sk-" (got "${apiKey.slice(0, 4)}…"). `
+ 'Generate a service-key from the ThinkFleet dashboard.',
)
}

return new ActivepiecesMemoryBackend({
baseUrl,
projectId,
apiKey,
chatbotId,
})
}

/**
* Human-readable label for a backend instance. Safe to log — never
* includes credentials. Used by the dispatcher to surface which
* backend got picked at startup.
*/
export function describeMemoryBackend(backend: MemoryBackend): string {
if (backend instanceof ActivepiecesMemoryBackend) {
const scope = backend.chatbotId
? `project:${backend.projectId}/chatbot:${backend.chatbotId}`
: `project:${backend.projectId}`
return `activepieces (${scope} @ ${backend.baseUrl})`
}
if (backend instanceof LocalFileMemoryBackend) {
return 'local-file'
}
return backend.constructor.name
}

export { LocalFileMemoryBackend, MemoryStore } from './store'
export type { LocalFileMemoryBackendConfig, MemoryStoreConfig } from './store'
export { ActivepiecesMemoryBackend, ActivepiecesMemoryError } from './activepieces-backend'
Expand Down
108 changes: 108 additions & 0 deletions test/mcp/default-memory-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Tests that the default MCP plugin set picks up the memory plugin
* automatically — and that misconfigured env vars disable memory but
* leave the rest of the server working.
*
* The memory plugin in the default set is what makes "install ThinkFleet
* Desktop → AI tools have persistent memory" a zero-config experience.
* Regressing this means every user-facing wiring path breaks silently.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createDispatcherState, disposeAll, type DispatcherState } from '../../src/mcp/dispatcher'
import { MEMORY_TOOLS } from '../../src/plugins/memory'

const SAAS_ENV_KEYS = [
'THINKFLEET_BASE_URL',
'THINKFLEET_PROJECT_ID',
'THINKFLEET_API_KEY',
'THINKFLEET_CHATBOT_ID',
]

let originalEnv: Record<string, string | undefined>
let consoleSpy: ReturnType<typeof vi.spyOn>
let state: DispatcherState | null = null

beforeEach(() => {
originalEnv = Object.fromEntries(SAAS_ENV_KEYS.map((k) => [k, process.env[k]]))
for (const k of SAAS_ENV_KEYS) delete process.env[k]
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(async () => {
if (state) {
await disposeAll(state).catch(() => {})
state = null
}
for (const k of SAAS_ENV_KEYS) {
if (originalEnv[k] === undefined) delete process.env[k]
else process.env[k] = originalEnv[k]
}
consoleSpy.mockRestore()
})

const memoryToolNames = MEMORY_TOOLS.map((t) => t.name).sort()

describe('default plugin set — memory wiring', () => {
it('registers every memory tool when no creds are set (local-file backend)', () => {
state = createDispatcherState()
const tools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_memory_'))
expect(tools.sort()).toEqual(memoryToolNames)
})

it('still registers memory tools when full SaaS creds are present', () => {
process.env.THINKFLEET_BASE_URL = 'https://app.thinkfleet.ai'
process.env.THINKFLEET_PROJECT_ID = 'proj_test_1234567890'
process.env.THINKFLEET_API_KEY = 'sk-test-aaaaaaaaaaaaaaaaaaaaaaaa'

state = createDispatcherState()
const tools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_memory_'))
expect(tools.sort()).toEqual(memoryToolNames)
})

it('logs which backend got selected on startup (no creds in the log line)', () => {
process.env.THINKFLEET_BASE_URL = 'https://app.thinkfleet.ai'
process.env.THINKFLEET_PROJECT_ID = 'proj_test_1234567890'
process.env.THINKFLEET_API_KEY = 'sk-test-aaaaaaaaaaaaaaaaaaaaaaaa'

state = createDispatcherState()

const logLines = consoleSpy.mock.calls.map((c) => String(c[0]))
const backendLine = logLines.find((l) => l.includes('memory backend:'))
expect(backendLine).toBeDefined()
expect(backendLine).toContain('activepieces')
expect(backendLine).not.toContain('sk-test')
})

it('disables memory but keeps the rest of the server alive on partial creds', () => {
process.env.THINKFLEET_BASE_URL = 'https://app.thinkfleet.ai'
// intentionally omit api key

state = createDispatcherState()

// Memory tools are gone.
const memTools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_memory_'))
expect(memTools).toEqual([])

// Other capabilities still registered.
const webTools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_browser_'))
expect(webTools.length).toBeGreaterThan(0)

// Disable reason surfaced to stderr (MCP clients show it).
const logLines = consoleSpy.mock.calls.map((c) => String(c[0]))
expect(logLines.some((l) => l.includes('memory plugin disabled'))).toBe(true)
})

it('disables memory on malformed API key without leaking the bad key', () => {
process.env.THINKFLEET_BASE_URL = 'https://app.thinkfleet.ai'
process.env.THINKFLEET_PROJECT_ID = 'proj_test'
process.env.THINKFLEET_API_KEY = 'totally-not-an-sk-key-very-secret'

state = createDispatcherState()

const memTools = Array.from(state.dispatcher.toolNames).filter((n) => n.startsWith('agentmark_memory_'))
expect(memTools).toEqual([])

const allLogs = consoleSpy.mock.calls.map((c) => String(c[0])).join('\n')
expect(allLogs).not.toContain('totally-not-an-sk-key-very-secret')
})
})
Loading
Loading