diff --git a/src/mcp/index.ts b/src/mcp/index.ts index f412582..c9005d8 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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. diff --git a/src/mcp/plugins/meta.ts b/src/mcp/plugins/meta.ts index 69da2cb..3808f1b 100644 --- a/src/mcp/plugins/meta.ts +++ b/src/mcp/plugins/meta.ts @@ -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' @@ -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 { +export function createMetaPlugin( + peers: ReadonlyArray, + config: MetaPluginConfig = {}, +): AgentMarkPlugin { + const serverName = config.serverName ?? 'agentmark' + const serverVersion = config.serverVersion ?? '0.7.0' + const handlers: Record = { agentmark_list_sessions: async (): Promise => { const merged: Record = {} @@ -36,10 +83,58 @@ export function createMetaPlugin(peers: ReadonlyArray): AgentMa } return { text: JSON.stringify(merged, null, 2) } }, + + agentmark_capabilities: async (args): Promise => { + 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 = {} + 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, } diff --git a/test/mcp/discovery.test.ts b/test/mcp/discovery.test.ts new file mode 100644 index 0000000..2d39925 --- /dev/null +++ b/test/mcp/discovery.test.ts @@ -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 { + const handlers: Record = {} + 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' }] }) + }) +})