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
1 change: 1 addition & 0 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export { createWebPlugin, type WebPlugin } from './plugins/web'
export { createPdfPlugin, type PdfPlugin } from './plugins/pdf'
export { createDesktopPlugin, type DesktopPlugin } from './plugins/desktop'
export { createMetaPlugin } from './plugins/meta'
export type { MetaPluginConfig } from './plugins/meta'

// Foundations Pack — OS-basics plugin (app launcher, clipboard, allowlisted
// filesystem, durable state K/V). Opt-in.
Expand Down
107 changes: 101 additions & 6 deletions src/mcp/plugins/meta.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
/**
* Meta MCP plugin.
*
* Provides `agentmark_list_sessions`, which surfaces the merged session
* descriptors from every other plugin. The plugin must be registered AFTER
* the plugins it introspects so its handler can ask them for descriptors.
* Provides cross-plugin introspection — what's loaded, what's
* configured, what sessions are open. Used by any AI agent that
* connects to the server to discover what capabilities are available
* without hardcoded assumptions.
*
* Tools:
* - agentmark_list_sessions: merged session descriptors
* - agentmark_capabilities: full server + plugin + tool catalog
*
* Must be registered AFTER the plugins it introspects so its handler
* can ask them for descriptors at call time. Late-registered plugins
* after meta won't appear (rare).
*/
import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../plugin'
import type { McpToolDef } from '../tool-defs'
Expand All @@ -20,14 +29,52 @@ const META_TOOLS: McpToolDef[] = [
properties: {},
},
},
{
name: 'agentmark_capabilities',
description:
'Return the full server + plugin + tool catalog for the running '
+ 'agentmark MCP server. Returns:\n'
+ ' - server: { name, version }\n'
+ ' - plugins: array of { name, version, tool_count, tools[] }\n'
+ ' - tools: flat tool list with plugin attribution\n'
+ ' - describe_sessions: merged config + session info per plugin\n'
+ '\nUse this when an AI agent first connects to discover what '
+ 'this server can do without hardcoded assumptions. The same '
+ 'agent code can then adapt to a minimal server (just web + pdf) '
+ 'or a full ThinkFleet Desktop install (memory + recipes + '
+ 'network + ...).',
inputSchema: {
type: 'object',
properties: {
include_tool_schemas: {
type: 'boolean',
description: 'When true, each tool entry includes its full inputSchema. Default: false (names + descriptions only).',
},
},
},
},
]

export interface MetaPluginConfig {
/** Server name to report in capabilities. */
serverName?: string
/** Server version to report in capabilities. */
serverVersion?: string
}

/**
* Create the meta plugin. Takes a `peers` array used to gather session
* descriptors at call time (not at construction time, so late-registered
* plugins are reflected).
* descriptors + tool catalogs at call time (not at construction time, so
* late-registered plugins are reflected). `serverInfo` lets the caller
* pin specific name/version fields; if omitted, defaults are used.
*/
export function createMetaPlugin(peers: ReadonlyArray<AgentMarkPlugin>): AgentMarkPlugin {
export function createMetaPlugin(
peers: ReadonlyArray<AgentMarkPlugin>,
config: MetaPluginConfig = {},
): AgentMarkPlugin {
const serverName = config.serverName ?? 'agentmark'
const serverVersion = config.serverVersion ?? '0.7.0'

const handlers: Record<string, ToolHandler> = {
agentmark_list_sessions: async (): Promise<DispatchResult> => {
const merged: Record<string, unknown> = {}
Expand All @@ -36,10 +83,58 @@ export function createMetaPlugin(peers: ReadonlyArray<AgentMarkPlugin>): AgentMa
}
return { text: JSON.stringify(merged, null, 2) }
},

agentmark_capabilities: async (args): Promise<DispatchResult> => {
const includeSchemas = args.include_tool_schemas === true

// Include meta itself in the plugin catalog so agents see the
// full picture (capabilities + list_sessions are useful tools too).
const allPlugins: AgentMarkPlugin[] = [
...peers,
{ name: 'meta', version: serverVersion, tools: META_TOOLS, handlers: {} },
]

const pluginEntries = allPlugins.map((p) => ({
name: p.name,
version: p.version,
tool_count: p.tools.length,
tools: p.tools.map((t) => ({
name: t.name,
description: t.description,
...(includeSchemas ? { input_schema: t.inputSchema } : {}),
})),
}))

const flatTools: Array<{ name: string; plugin: string; description: string }> = []
for (const plugin of allPlugins) {
for (const tool of plugin.tools) {
flatTools.push({
name: tool.name,
plugin: plugin.name,
description: tool.description,
})
}
}

const describe: Record<string, unknown> = {}
for (const peer of peers) {
if (peer.describeSessions) Object.assign(describe, peer.describeSessions())
}

return {
text: JSON.stringify({
server: { name: serverName, version: serverVersion },
plugins: pluginEntries,
tools: flatTools,
describe_sessions: describe,
}, null, 2),
}
},
}

return {
name: 'meta',
version: serverVersion,
tools: META_TOOLS,
handlers,
}
Expand Down
119 changes: 119 additions & 0 deletions test/mcp/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Tests for agentmark_capabilities — the discovery tool that lets any
* AI agent connecting to the MCP server introspect what's available.
*/
import { describe, it, expect } from 'vitest'
import { Dispatcher, type AgentMarkPlugin, type McpToolDef } from '../../src/mcp'
import { createMetaPlugin } from '../../src/mcp/plugins/meta'

function makePlugin(name: string, tools: McpToolDef[], extras: Partial<AgentMarkPlugin> = {}): AgentMarkPlugin {
const handlers: Record<string, AgentMarkPlugin['handlers'][string]> = {}
for (const t of tools) {
handlers[t.name] = async () => ({ text: `${t.name} called` })
}
return { name, version: '0.1.0', tools, handlers, ...extras }
}

function toolDef(name: string): McpToolDef {
return { name, description: `Demo tool ${name}.`, inputSchema: { type: 'object', properties: {} } }
}

describe('agentmark_capabilities — discovery surface', () => {
it('reports server name, version, plugins, and tools', async () => {
const alpha = makePlugin('alpha', [toolDef('alpha_one'), toolDef('alpha_two')])
const beta = makePlugin('beta', [toolDef('beta_one')])
const meta = createMetaPlugin([alpha, beta], { serverName: 'agentmark-test', serverVersion: '9.9.9' })

const dispatcher = new Dispatcher([alpha, beta, meta])
const result = await dispatcher.dispatch('agentmark_capabilities', {})
expect(result.isError).toBeFalsy()
const body = JSON.parse(result.text)

expect(body.server).toEqual({ name: 'agentmark-test', version: '9.9.9' })

const pluginNames = body.plugins.map((p: { name: string }) => p.name).sort()
expect(pluginNames).toEqual(['alpha', 'beta', 'meta'])

const alphaInfo = body.plugins.find((p: { name: string }) => p.name === 'alpha')
expect(alphaInfo.tool_count).toBe(2)
expect(alphaInfo.tools.map((t: { name: string }) => t.name).sort()).toEqual(['alpha_one', 'alpha_two'])
})

it('flat tools list includes every plugin\'s tools with plugin attribution', async () => {
const alpha = makePlugin('alpha', [toolDef('alpha_one')])
const meta = createMetaPlugin([alpha])
const dispatcher = new Dispatcher([alpha, meta])

const result = await dispatcher.dispatch('agentmark_capabilities', {})
const body = JSON.parse(result.text)
const flat = body.tools as Array<{ name: string; plugin: string }>

expect(flat.find((t) => t.name === 'alpha_one')?.plugin).toBe('alpha')
// Meta's own tools should be discoverable too.
expect(flat.find((t) => t.name === 'agentmark_capabilities')?.plugin).toBe('meta')
expect(flat.find((t) => t.name === 'agentmark_list_sessions')?.plugin).toBe('meta')
})

it('omits input_schema by default (keeps payloads small)', async () => {
const alpha = makePlugin('alpha', [{
name: 'alpha_one',
description: 'x',
inputSchema: { type: 'object', properties: { foo: { type: 'string' } }, required: ['foo'] },
}])
const meta = createMetaPlugin([alpha])
const dispatcher = new Dispatcher([alpha, meta])

const result = await dispatcher.dispatch('agentmark_capabilities', {})
const body = JSON.parse(result.text)
const alphaPlugin = body.plugins.find((p: { name: string }) => p.name === 'alpha')
expect(alphaPlugin.tools[0].input_schema).toBeUndefined()
})

it('includes input_schema when include_tool_schemas=true', async () => {
const alpha = makePlugin('alpha', [{
name: 'alpha_one',
description: 'x',
inputSchema: { type: 'object', properties: { foo: { type: 'string' } }, required: ['foo'] },
}])
const meta = createMetaPlugin([alpha])
const dispatcher = new Dispatcher([alpha, meta])

const result = await dispatcher.dispatch('agentmark_capabilities', { include_tool_schemas: true })
const body = JSON.parse(result.text)
const alphaPlugin = body.plugins.find((p: { name: string }) => p.name === 'alpha')
expect(alphaPlugin.tools[0].input_schema).toEqual({
type: 'object',
properties: { foo: { type: 'string' } },
required: ['foo'],
})
})

it('merges describeSessions() output from every plugin into describe_sessions', async () => {
const alpha = makePlugin('alpha', [toolDef('a')], {
describeSessions: () => ({ alpha_state: { count: 3 } }),
})
const beta = makePlugin('beta', [toolDef('b')], {
describeSessions: () => ({ beta_state: { active: true } }),
})
const meta = createMetaPlugin([alpha, beta])
const dispatcher = new Dispatcher([alpha, beta, meta])

const result = await dispatcher.dispatch('agentmark_capabilities', {})
const body = JSON.parse(result.text)
expect(body.describe_sessions).toEqual({
alpha_state: { count: 3 },
beta_state: { active: true },
})
})

it('agentmark_list_sessions still works (regression check on the existing tool)', async () => {
const alpha = makePlugin('alpha', [toolDef('a')], {
describeSessions: () => ({ alphas: [{ id: 'a1' }] }),
})
const meta = createMetaPlugin([alpha])
const dispatcher = new Dispatcher([alpha, meta])

const result = await dispatcher.dispatch('agentmark_list_sessions', {})
expect(JSON.parse(result.text)).toEqual({ alphas: [{ id: 'a1' }] })
})
})
Loading