Skip to content
Draft
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
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,70 @@ Two calls instead of 26 tools cluttering the context.

Per-server `idleTimeout` overrides the global setting.

### MCP Policy Layer

You can add a generic policy layer per server to control what the agent can see and send.

```json
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"policy": {
"allowedTools": ["search_repositories", "get_file_contents", "create_issue"],
"allowedResources": [],
"allowedPrompts": [],
"toolPolicies": {
"get_file_contents": {
"defaults": { "ref": "main" },
"requireKeys": ["owner", "repo", "path"],
"forbidKeys": ["token"],
"allowedValues": {
"ref": ["main", "develop"]
}
},
"create_issue": {
"forbidKeys": ["assignees"],
"injectIntoEachItem": { "source": "pi" },
"allowedValuesEach": {
"source": ["pi"]
},
"forbidPerItemKeys": ["admin"],
"requirePerItemKeys": ["source"]
}
}
}
}
}
}
```

**Visibility rules**

- `allowedTools`, `allowedResources`, `allowedPrompts`
- Missing or empty list = allow all
- Non-empty list = only listed entries are visible/usable
- If an allowlist names tools/resources/prompts that the server does not actually expose, the adapter logs warnings so you can fix stale config

**Per-tool request rules**

Inside `toolPolicies[toolName]`:

- `defaults` — fill missing top-level args
- `allowedValues` — restrict top-level arg values
- `forbidKeys` — reject requests containing these top-level keys
- `requireKeys` — require these top-level keys
- `injectIntoEachItem` — fill missing keys into each object in `items`
- `allowedValuesEach` — restrict values inside each object in `items`
- `forbidPerItemKeys` — reject per-item keys
- `requirePerItemKeys` — require per-item keys

**Validation**

- Policy config is validated fail-fast at startup
- Invalid combinations such as the same key appearing in both `forbidKeys` and `requireKeys` reject adapter initialization before server connection work begins

### Direct Tools

By default, all MCP tools are accessed through the single `mcp` proxy tool. This keeps context small but means the LLM has to discover tools via search. If you want specific tools to show up directly in the agent's tool list — alongside `read`, `bash`, `edit`, etc. — add `directTools` to your config.
Expand Down
184 changes: 184 additions & 0 deletions __tests__/policy-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// policy-allowlist.test.ts — RED phase: allowlist filtering functions (Slice 2)
import { describe, it, expect } from "vitest";
import {
isToolAllowed,
isResourceAllowed,
isPromptAllowed,
filterAllowedTools,
filterAllowedResources,
filterAllowedPrompts,
type ServerPolicy,
} from "../policy.js";

// Minimal metadata shapes matching MCP SDK conventions
interface ToolMeta { name: string; description?: string }
interface ResourceMeta { uri: string; name?: string }
interface PromptMeta { name: string; description?: string }

// ---------------------------------------------------------------------------
// isToolAllowed
// ---------------------------------------------------------------------------
describe("isToolAllowed", () => {
it("returns true when allowedTools is undefined", () => {
const policy: ServerPolicy = {};
expect(isToolAllowed(policy, "read_file")).toBe(true);
});

it("returns true when allowedTools is empty", () => {
const policy: ServerPolicy = { allowedTools: [] };
expect(isToolAllowed(policy, "read_file")).toBe(true);
});

it("returns true when tool is in allowedTools", () => {
const policy: ServerPolicy = { allowedTools: ["read_file", "write_file"] };
expect(isToolAllowed(policy, "read_file")).toBe(true);
});

it("returns false when tool is NOT in allowedTools", () => {
const policy: ServerPolicy = { allowedTools: ["read_file"] };
expect(isToolAllowed(policy, "delete_file")).toBe(false);
});
});

// ---------------------------------------------------------------------------
// isResourceAllowed
// ---------------------------------------------------------------------------
describe("isResourceAllowed", () => {
it("returns true when allowedResources is undefined", () => {
const policy: ServerPolicy = {};
expect(isResourceAllowed(policy, "file:///readme.md")).toBe(true);
});

it("returns true when allowedResources is empty", () => {
const policy: ServerPolicy = { allowedResources: [] };
expect(isResourceAllowed(policy, "file:///readme.md")).toBe(true);
});

it("returns true when resource URI is in allowedResources", () => {
const policy: ServerPolicy = { allowedResources: ["file:///readme.md", "file:///src/index.ts"] };
expect(isResourceAllowed(policy, "file:///readme.md")).toBe(true);
});

it("returns false when resource URI is NOT in allowedResources", () => {
const policy: ServerPolicy = { allowedResources: ["file:///readme.md"] };
expect(isResourceAllowed(policy, "file:///secret.txt")).toBe(false);
});
});

// ---------------------------------------------------------------------------
// isPromptAllowed
// ---------------------------------------------------------------------------
describe("isPromptAllowed", () => {
it("returns true when allowedPrompts is undefined", () => {
const policy: ServerPolicy = {};
expect(isPromptAllowed(policy, "summarize")).toBe(true);
});

it("returns true when allowedPrompts is empty", () => {
const policy: ServerPolicy = { allowedPrompts: [] };
expect(isPromptAllowed(policy, "summarize")).toBe(true);
});

it("returns true when prompt is in allowedPrompts", () => {
const policy: ServerPolicy = { allowedPrompts: ["summarize", "translate"] };
expect(isPromptAllowed(policy, "summarize")).toBe(true);
});

it("returns false when prompt is NOT in allowedPrompts", () => {
const policy: ServerPolicy = { allowedPrompts: ["summarize"] };
expect(isPromptAllowed(policy, "generate_code")).toBe(false);
});
});

// ---------------------------------------------------------------------------
// filterAllowedTools
// ---------------------------------------------------------------------------
describe("filterAllowedTools", () => {
const tools: ToolMeta[] = [
{ name: "read_file", description: "Read a file" },
{ name: "write_file", description: "Write a file" },
{ name: "delete_file", description: "Delete a file" },
];

it("returns all tools when allowedTools is undefined", () => {
const policy: ServerPolicy = {};
expect(filterAllowedTools(policy, tools)).toEqual(tools);
});

it("returns all tools when allowedTools is empty", () => {
const policy: ServerPolicy = { allowedTools: [] };
expect(filterAllowedTools(policy, tools)).toEqual(tools);
});

it("returns only allowed tools", () => {
const policy: ServerPolicy = { allowedTools: ["read_file", "write_file"] };
expect(filterAllowedTools(policy, tools)).toEqual([tools[0], tools[1]]);
});

it("returns empty array when no tools match allowedTools", () => {
const policy: ServerPolicy = { allowedTools: ["bash"] };
expect(filterAllowedTools(policy, tools)).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// filterAllowedResources
// ---------------------------------------------------------------------------
describe("filterAllowedResources", () => {
const resources: ResourceMeta[] = [
{ uri: "file:///readme.md", name: "README" },
{ uri: "file:///src/index.ts", name: "Index" },
{ uri: "file:///secret.txt", name: "Secret" },
];

it("returns all resources when allowedResources is undefined", () => {
const policy: ServerPolicy = {};
expect(filterAllowedResources(policy, resources)).toEqual(resources);
});

it("returns all resources when allowedResources is empty", () => {
const policy: ServerPolicy = { allowedResources: [] };
expect(filterAllowedResources(policy, resources)).toEqual(resources);
});

it("returns only allowed resources", () => {
const policy: ServerPolicy = { allowedResources: ["file:///readme.md"] };
expect(filterAllowedResources(policy, resources)).toEqual([resources[0]]);
});

it("returns empty array when no resources match allowedResources", () => {
const policy: ServerPolicy = { allowedResources: ["file:///other.txt"] };
expect(filterAllowedResources(policy, resources)).toEqual([]);
});
});

// ---------------------------------------------------------------------------
// filterAllowedPrompts
// ---------------------------------------------------------------------------
describe("filterAllowedPrompts", () => {
const prompts: PromptMeta[] = [
{ name: "summarize", description: "Summarize text" },
{ name: "translate", description: "Translate text" },
{ name: "generate_code", description: "Generate code" },
];

it("returns all prompts when allowedPrompts is undefined", () => {
const policy: ServerPolicy = {};
expect(filterAllowedPrompts(policy, prompts)).toEqual(prompts);
});

it("returns all prompts when allowedPrompts is empty", () => {
const policy: ServerPolicy = { allowedPrompts: [] };
expect(filterAllowedPrompts(policy, prompts)).toEqual(prompts);
});

it("returns only allowed prompts", () => {
const policy: ServerPolicy = { allowedPrompts: ["summarize", "translate"] };
expect(filterAllowedPrompts(policy, prompts)).toEqual([prompts[0], prompts[1]]);
});

it("returns empty array when no prompts match allowedPrompts", () => {
const policy: ServerPolicy = { allowedPrompts: ["review"] };
expect(filterAllowedPrompts(policy, prompts)).toEqual([]);
});
});
Loading