From 8b9bbd55943b15b55dfa52b63b13dda596c7af25 Mon Sep 17 00:00:00 2001 From: rrader26 Date: Tue, 12 May 2026 10:22:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20agentmark=5Fcapabilities=20?= =?UTF-8?q?=E2=80=94=20discovery=20tool=20for=20any-AI-can-connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a self-describing capability call to the meta plugin so any AI agent connecting to the MCP server can discover what's available in one call — server name + version, loaded plugins + versions, every tool with plugin attribution, and merged session state. Tool shipped (1): agentmark_capabilities { include_tool_schemas?: boolean } Returned shape: { server: { name, version }, plugins: [{ name, version, tool_count, tools: [{ name, description }] }], tools: [{ name, plugin, description }], describe_sessions: { ...merged config + session info per plugin... } } Set include_tool_schemas=true to also get each tool's full inputSchema (useful for AIs that want to build a typed client; off by default to keep the response payload small). Why this matters: same agent code can connect to a minimal agentmark server (just web + pdf) or a full ThinkFleet Desktop install (memory + recipes + network + system + …) and adapt at runtime. No hardcoded assumptions about what's wired up. Pairs with the strategic direction of being the open MCP layer that ANY AI agent (Claude Code, Cursor, Codex, Windsurf, future) can connect to. Also: createMetaPlugin() now accepts an optional MetaPluginConfig with serverName + serverVersion so deployments can pin those values for their own builds (instead of the hardcoded defaults). Tests (6 new, 413 total): server info reporting, plugin enumeration, flat tool list with plugin attribution, schema-inclusion toggle, describeSessions merging, list_sessions regression check. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 1 + src/mcp/plugins/meta.ts | 107 +++++++++++++++++++++++++++++++-- test/mcp/discovery.test.ts | 119 +++++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 test/mcp/discovery.test.ts 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' }] }) + }) +})