npm package: @singl/periphery
Description: Peripheral nervous system for LLMs - afferent (Ψ observes) and efferent (~ acts) pathways
Latest version: 0.1.2

# @agi/periphery

> MCP server framework with eyes (discovery) and hands (action) for complete agency

## Quick Start

```bash
# Install
bun add @agi/periphery @modelcontextprotocol/sdk

# Create your tools
# See examples/ directory

# Build and run
bun run build
node dist/examples/server.js
```

## What is this?

A framework for building MCP (Model Context Protocol) servers using the **eyes+hands** pattern:

- **👁️ Eyes (Discovery)**: Observe state via S-expressions (read-only queries)
- **🤲 Hands (Action)**: Mutate state via batched actions (validated mutations)

Together they create **complete agency**: observe → decide → act → observe → ∞

## Why both tools?

| With Discovery only | With Action only | With Both |
|---------------------|------------------|-----------|
| Can think | Can change | Can learn |
| Observer | Blind actor | **Complete agent** |
| ❌ Incomplete | ❌ Incomplete | ✅ Complete |

## Installation

```bash
bun add @agi/periphery @modelcontextprotocol/sdk
# or
npm install @agi/periphery @modelcontextprotocol/sdk
```

## Example: Todo Server

### Discovery Tool (Eyes)

```typescript
import { DiscoveryToolInteraction } from "@agi/periphery";
import * as z from "zod";

export class TodoDiscoveryTool extends DiscoveryToolInteraction<{}> {
  description = "Query todos using S-expressions";

  async registerFunctions() {
    this.registerFunction(
      "get-all",
      "Get all todos",
      [],
      async () => todos
    );

    this.registerFunction(
      "filter-completed",
      "Filter by completion status",
      [z.boolean()],
      async (completed) => todos.filter(t => t.completed === completed)
    );

    return async () => {}; // cleanup
  }
}
```

**Query examples**:
```scheme
; Get all todos
(get-all)

; Get incomplete todos
(filter-completed false)

; Compositional query using Fantasy Land
(fmap (lambda (t) (get 'text t)) (filter-completed false))
```

### Action Tool (Hands)

```typescript
import { ActionToolInteraction } from "@agi/periphery";
import { Context } from "hono";
import * as z from "zod";

export class TodoActionTool extends ActionToolInteraction<{}> {
  description = "Mutate todo state";
  contextSchema = {} as const;

  constructor(context: Context) {
    super(context);

    this.registerAction({
      name: "create",
      description: "Create new todo",
      props: {
        id: z.string(),
        text: z.string()
      },
      handler: async (context, { id, text }) => {
        todos.push({ id, text, completed: false });
        return { created: { id, text } };
      }
    });

    this.registerAction({
      name: "complete",
      description: "Mark todo as completed",
      props: {
        id: z.string()
      },
      handler: async (context, { id }) => {
        const todo = todos.find(t => t.id === id);
        if (!todo) throw new Error(`Todo ${id} not found`);
        todo.completed = true;
        return { completed: todo };
      }
    });
  }
}
```

**Action examples**:
```json
{
  "actions": [
    ["create", "1", "Learn MCP"],
    ["create", "2", "Build tools"]
  ]
}
```

```json
{
  "actions": [
    ["complete", "1"]
  ]
}
```

### Server Entry Point

```typescript
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { MCPServerBridge } from "@agi/periphery";
import { TodoDiscoveryTool } from "./TodoDiscoveryTool.js";
import { TodoActionTool } from "./TodoActionTool.js";

async function main() {
  const bridge = new MCPServerBridge(
    {
      name: "todo-server",
      version: "1.0.0"
    },
    TodoDiscoveryTool,  // Eyes
    TodoActionTool      // Hands
  );

  const transport = new StdioServerTransport();
  await bridge.connect(transport);

  console.error("✨ Todo server running");
}

main().catch(console.error);
```

## Complete Workflow Example

```typescript
// 1. Create todos
{
  "name": "TodoActionTool",
  "arguments": {
    "actions": [
      ["create", "1", "Learn MCP"],
      ["create", "2", "Build tools"],
      ["create", "3", "Deploy"]
    ]
  }
}

// 2. Query all todos
{
  "name": "TodoDiscoveryTool",
  "arguments": {
    "expr": "(get-all)"
  }
}
// Returns: all 3 todos

// 3. Complete first todo
{
  "name": "TodoActionTool",
  "arguments": {
    "actions": [["complete", "1"]]
  }
}

// 4. Query incomplete todos
{
  "name": "TodoDiscoveryTool",
  "arguments": {
    "expr": "(filter-completed false)"
  }
}
// Returns: todos 2 and 3

// 5. Complete remaining todos
{
  "name": "TodoActionTool",
  "arguments": {
    "actions": [
      ["complete", "2"],
      ["complete", "3"]
    ]
  }
}

// 6. Verify all complete
{
  "name": "TodoDiscoveryTool",
  "arguments": {
    "expr": "(filter-completed false)"
  }
}
// Returns: [] (empty)
```

## API Reference

### DiscoveryToolInteraction

**Purpose**: Read-only queries using S-expressions

```typescript
abstract class DiscoveryToolInteraction<TContext> {
  // Override these
  abstract description: string;
  abstract registerFunctions(context: TContext): Promise<() => Promise<void>>;

  // Optional context schema
  contextSchema?: Record<string, z.ZodType>;

  // Register domain functions
  protected registerFunction<T>(
    name: string,
    description: string,
    params: T,  // Zod schemas
    handler: (...args: infer<T>) => any
  ): void;
}
```

**S-expression environment includes**:
- Fantasy Land combinators: `fmap`, `chain`, `filter`, `compose`, `pipe`
- Ramda functions (via @agi/arrival)
- Your registered functions

### ActionToolInteraction

**Purpose**: Batched state mutations with validation

```typescript
abstract class ActionToolInteraction<TContext> {
  // Override these
  abstract description: string;
  abstract contextSchema: Record<string, z.ZodType>;

  // Register actions in constructor
  protected registerAction<TProps>({
    name: string,
    description: string,
    props: Record<string, z.ZodType>,
    handler: (context: TContext, props: TProps) => any
  }): void;
}
```

**Action validation**:
- All actions validated before executing any
- Fails fast on validation error (nothing executed)
- Returns partial results on runtime error

### MCPServerBridge

**Purpose**: Connect framework to MCP SDK

```typescript
class MCPServerBridge {
  constructor(
    serverInfo: { name: string; version: string },
    ...tools: Constructor<ToolInteraction<any>>[]
  );

  async connect(transport: Transport): Promise<void>;
}
```

## Testing

### Run with MCP Inspector

```bash
# Install inspector
npm install -g @modelcontextprotocol/inspector

# Build your server
bun run build

# Launch inspector
npx @modelcontextprotocol/inspector node dist/examples/server.js
```

This opens a web UI where you can:
- List available tools
- Test tool calls
- See request/response JSON
- Verify schema generation

### Manual Testing

```bash
# Build
bun run build

# Run server (stdio mode)
node dist/examples/server.js

# Send JSON-RPC over stdin
# (requires manual JSON-RPC formatting)
```

## Project Structure

```
packages/agency/
├── src/
│   ├── framework/
│   │   ├── ToolInteraction.ts           # Base class
│   │   ├── DiscoveryToolInteraction.ts  # Eyes (S-expressions)
│   │   ├── ActionToolInteraction.ts     # Hands (batched actions)
│   │   └── MCPServerBridge.ts           # SDK integration
│   ├── examples/
│   │   ├── TodoDiscoveryTool.ts         # Example eyes
│   │   ├── TodoActionTool.ts            # Example hands
│   │   └── server.ts                    # Example server
│   └── index.ts                         # Public exports
├── package.json
├── tsconfig.json
├── README.md                            # This file
└── ARCHITECTURE.md                      # Design decisions
```

## Philosophy

### Eyes (Discovery)

**What**: Pure functional queries over system state

**Why**:
- Observation shouldn't change what's observed
- Composable via S-expressions
- Safe to retry (no side effects)
- Fantasy Land algebraic structures

**How**:
- Register functions in LIPS environment
- Functions become S-expression primitives
- Compose using `fmap`, `chain`, `filter`, etc.

### Hands (Action)

**What**: Batched mutations with validation

**Why**:
- Explicit intent to modify
- All-or-nothing validation prevents partial states
- Context sharing across batch
- Clear error reporting

**How**:
- Register actions with Zod schemas
- Actions as tuples: `["action-name", ...args]`
- Validate all, then execute sequentially

### Together

```
Eyes → Brain → Hands → Eyes → ∞

Observe → Decide → Act → Verify → Learn
```

This isn't just REPL. This is **complete agency**.

## Advanced Usage

### Context-Aware Tools

```typescript
// Discovery with context
class ProjectDiscoveryTool extends DiscoveryToolInteraction<{
  projectId: string;
}> {
  contextSchema = {
    projectId: z.string()
  };

  async registerFunctions(context) {
    const { projectId } = context;

    this.registerFunction("get-tasks", "Get project tasks", [],
      async () => await db.tasks.where({ projectId }));

    return async () => {};
  }
}

// Action with context
class ProjectActionTool extends ActionToolInteraction<{
  projectId: string;
}> {
  contextSchema = {
    projectId: z.string()
  } as const;

  constructor(context: Context) {
    super(context);

    this.registerAction({
      name: "create-task",
      props: {
        projectId: z.string(), // From context
        title: z.string()       // From args
      },
      handler: async (context, { title }) => {
        // context.projectId available here
        return await db.tasks.create({
          projectId: context.projectId,
          title
        });
      }
    });
  }
}
```

### Compositional Queries

```scheme
; Get IDs of incomplete todos
(fmap (lambda (t) (get 'id t))
      (filter-completed false))

; Count incomplete todos
(length (filter-completed false))

; Chain operations
(chain (lambda (id) (get-by-id id))
       (list "1" "2" "3"))

; Compose functions
((compose count-items filter-active) items)
```

### Error Handling

**Discovery errors**:
```typescript
// Timeout protection (5 seconds)
try {
  await tool.executeTool({ expr: "(infinite-loop)" });
} catch (e) {
  // Error: "Timeout"
}

// Invalid function
try {
  await tool.executeTool({ expr: "(nonexistent)" });
} catch (e) {
  // Error: "nonexistent: not defined"
}
```

**Action errors**:
```typescript
// Validation error (before execution)
const result = await tool.executeTool({
  actions: [["invalid-action", "arg"]]
});
// result.success === false
// result.validation === "failed"
// result.errors === [...]

// Runtime error (during execution)
const result = await tool.executeTool({
  actions: [
    ["create", "1", "text"],
    ["create", "1", "duplicate"], // fails here
    ["create", "2", "text"]       // never executed
  ]
});
// result.partial === true
// result.executed === 1
// result.failedAction.error === "..."
```

## Dependencies

- `@modelcontextprotocol/sdk` - MCP protocol implementation
- `@agi/arrival` - S-expression serialization and LIPS integration
- `zod` - Schema validation
- `hono` - Context type (minimal usage)

## License

MIT

## Troubleshooting

### HTTP 524 Timeouts (Cloudflare Tunnel)

If using periphery through Cloudflare tunnel and seeing 524 timeout errors:

**Diagnosis**: Periphery operations complete quickly (typically <10ms), but responses may not reach the client through tunnel congestion.

**Solutions**:
- Use `localhost:7777` directly when working locally (bypasses tunnel)
- Accept occasional tunnel unreliability for remote access
- Consider ngrok/bore as more stable alternatives to Cloudflare free tier

**Verify periphery is working**:
```bash
curl http://localhost:7777/health
# Should return instantly with session info
```

Periphery logs show `🔧 discover` when operations start. Operations complete fast - if you see cancellations, the issue is network/tunnel, not periphery.

## Contributing

See [ARCHITECTURE.md](./ARCHITECTURE.md) for design decisions and extension points.

---

**Eyes to observe. Hands to manipulate. Together: complete agency.**

~ ≡ ∞