Skip to content

MCP: connect external tool services ​

MCP integration has three layers: the application opens an SDK connection, a neutral adapter implements Ditto's McpClient, and a Graph calls INTERACTION.ACT.MCP. Ditto does not bundle an MCP SDK or start processes automatically from server names.

1. Install application dependencies ​

In your npm application:

sh
npm install @codesoul-co/ditto @modelcontextprotocol/sdk @modelcontextprotocol/server-filesystem
mkdir -p workspace
printf 'Hello from MCP\n' > workspace/hello.txt
node examples/handbook/mcp.mjs ./workspace hello.txt .

The last argument identifies the application directory containing the optional SDK. The complete .mjs below starts a local filesystem server, discovers tools, reads an actual file and converts the result to an Observation.

In the source repository, keep these SDKs out of framework runtime dependencies. Install them in a separate application dependency directory and pass that directory as the final argument.

2. Complete connection and Graph ​

js
import { resolve } from 'node:path';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { createDitto, graph, createInteractionWorker } from '@codesoul-co/ditto';

/** dependencies is an application directory with the official SDK and filesystem server installed. */
export async function runMcp(workspace, filename, dependencies = process.cwd()) {
  const requireSdk = createRequire(resolve(dependencies, 'package.json'));
  const { Client } = requireSdk('@modelcontextprotocol/sdk/client/index.js');
  const { StdioClientTransport } = requireSdk('@modelcontextprotocol/sdk/client/stdio.js');
  const server = requireSdk.resolve('@modelcontextprotocol/server-filesystem/dist/index.js');
  const client = new Client({ name: 'ditto-handbook', version: '1.0.0' });
  const transport = new StdioClientTransport({ command: process.execPath, args: [server, resolve(workspace)], stderr: 'inherit' });
  let runtime;
  try {
    await client.connect(transport);
    const adapter = {
      listTools: (params, options) => client.listTools(params, options),
      async callTool(params, options) {
        const result = await client.callTool(params, undefined, options);
        return { content: result.content,
          ...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }),
          ...(result.isError === undefined ? {} : { isError: result.isError }) };
      },
    };
    runtime = createDitto({ sandbox: { mcp: ['files'] },
      workers: [createInteractionWorker({ mcp: { files: adapter } })] });
    const plan = graph('read-via-mcp')
      .node('discovery', 'INTERACTION.ACT.MCP', [], () => ({ operation: 'discover', server: 'files' }))
      .node('read', 'INTERACTION.ACT.MCP', ['discovery'], (path, { discovery }) => {
        if (discovery.operation !== 'discover' || !discovery.capabilities.some(tool => tool.name === 'read_text_file')) throw new Error('Required MCP tool missing');
        return { operation: 'invoke', server: 'files', call: { id: 'read-1', name: 'read_text_file', arguments: { path } } };
      })
      .node('observation', 'INTERACTION.OBSERVE', ['read'], (_input, { read }) => {
        if (read.operation !== 'invoke') throw new Error('Expected invocation');
        return { result: read.result };
      });
    const output = await runtime.run(plan, resolve(workspace, filename), { signal: AbortSignal.timeout(15_000) });
    if (output.observation.status !== 'success') throw new Error(output.observation.error?.message ?? 'MCP read failed');
    return output.observation;
  } finally {
    try { await runtime?.close(); } finally { try { await client.close(); } finally { await transport.close(); } }
  }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  if (!process.argv[2] || !process.argv[3]) throw new Error('Usage: node mcp.mjs WORKSPACE RELATIVE_FILE [DEPENDENCY_DIRECTORY]');
  console.log(JSON.stringify(await runMcp(process.argv[2], process.argv[3], process.argv[4]), null, 2));
}

Successful output retains source: "files:read_text_file", the original callId and the actual file content. A missing file fails instead of returning an empty successful result.

3. Why an adapter is needed ​

InterfaceDitto expectsApplication responsibility
listTools(params,options)Tools and optional nextCursorPreserve SDK this binding, forward signal, map inputSchema
callTool(params,options)content / structuredContent / references / isErrorSDK call options are the third argument; the second is the result schema
closeOutside McpClientClose the client and transport after Runtime drains

SDK content blocks may include specific multimodal types. Normalize unsupported blocks explicitly or represent them as allowed references. Type assertions do not make incompatible unions into generic messages.

4. Discovery and invocation have different results ​

{operation:"discover",server:"files"} returns capabilities. {operation:"invoke",server:"files",call:{...}} returns result. Check operation before accessing fields. Invocation result is ExternalResult, not NodeResult.

Discovery handles pagination. McpRegistry bounds pages and tool counts and rejects repeated cursors and invalid schemas. Large servers can configure maxDiscoveryPages/maxCapabilities, but discovery should remain bounded.

5. Permissions and boundaries ​

Sandbox mcp: ["files"] allows a server, not individual tools within it. The filesystem server enforces its root directory. For read-only access, filter listTools and reject other names in callTool.

Remote HTTP MCP authentication, TLS, session renewal and connection settings belong to the SDK transport. A trusted controller selects hosts and tokens; do not take them directly from model arguments. The Graph and neutral adapter can remain unchanged when using HTTP transport.

6. Add MCP to an Agent Loop ​

Discover capabilities initially and when the server changes. Convert permitted tools into model action descriptors. INFER only requests an action; validate server, name and schema before the MCP Graph executes it. Write the OBSERVE result into Context, then let the Loop continue, return or escalate.

Use a callId per logical call, plus server-supported idempotency identifiers for side effects. A transport timeout does not guarantee server rollback. Recovery still requires effect reconciliation.

MCP API · Real MCP verification · ReAct

Ditto · @codesoul-co/ditto · Node.js 24+