arc-gate-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arc_gate_mcp-0.1.0/PKG-INFO +78 -0
- arc_gate_mcp-0.1.0/README.md +64 -0
- arc_gate_mcp-0.1.0/arc_gate_mcp/__init__.py +11 -0
- arc_gate_mcp-0.1.0/arc_gate_mcp/arc_gate_mcp.py +474 -0
- arc_gate_mcp-0.1.0/arc_gate_mcp.egg-info/PKG-INFO +78 -0
- arc_gate_mcp-0.1.0/arc_gate_mcp.egg-info/SOURCES.txt +10 -0
- arc_gate_mcp-0.1.0/arc_gate_mcp.egg-info/dependency_links.txt +1 -0
- arc_gate_mcp-0.1.0/arc_gate_mcp.egg-info/entry_points.txt +2 -0
- arc_gate_mcp-0.1.0/arc_gate_mcp.egg-info/requires.txt +2 -0
- arc_gate_mcp-0.1.0/arc_gate_mcp.egg-info/top_level.txt +1 -0
- arc_gate_mcp-0.1.0/pyproject.toml +25 -0
- arc_gate_mcp-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arc-gate-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime governance for MCP tool calls — Arc Gate for the MCP protocol layer
|
|
5
|
+
Author-email: Hannah Nine <9hannahnine@gmail.com>
|
|
6
|
+
License: AGPL-3.0
|
|
7
|
+
Project-URL: Homepage, https://bendexgeometry.com/gate
|
|
8
|
+
Project-URL: Repository, https://github.com/9hannahnine-jpg/arc-gate-mcp
|
|
9
|
+
Keywords: mcp,prompt-injection,ai-security,llm,agent,runtime-governance
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: mcp>=1.0.0
|
|
13
|
+
Requires-Dist: httpx>=0.25.0
|
|
14
|
+
|
|
15
|
+
# arc-gate-mcp
|
|
16
|
+
|
|
17
|
+
**Runtime governance for MCP tool calls.**
|
|
18
|
+
|
|
19
|
+
Arc Gate MCP sits between your agent and any MCP server. It intercepts all tool call results and enforces instruction-authority boundaries before the agent processes them.
|
|
20
|
+
|
|
21
|
+
When a tool result contains injected instructions — a poisoned document, a malicious webpage, a hostile database row — Arc Gate blocks them before they reach the agent.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install arc-gate-mcp
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Full proxy (wraps any MCP server)
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from arc_gate_mcp import ArcGateMCPProxy
|
|
35
|
+
|
|
36
|
+
proxy = ArcGateMCPProxy(
|
|
37
|
+
upstream_url="http://localhost:8000/sse",
|
|
38
|
+
policy_mode="rag_assistant",
|
|
39
|
+
)
|
|
40
|
+
proxy.run()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Per-tool guard
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from arc_gate_mcp import ArcGateToolGuard
|
|
47
|
+
|
|
48
|
+
guard = ArcGateToolGuard(policy_mode="rag_assistant")
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
async def read_document(path: str) -> str:
|
|
52
|
+
content = read_file(path)
|
|
53
|
+
return guard.check(content, tool_name="read_document")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### CLI
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
arc-gate-mcp --upstream http://localhost:8000/sse --policy rag_assistant
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Policy modes
|
|
63
|
+
|
|
64
|
+
| Mode | Behavior |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `balanced` | Block on detected injection |
|
|
67
|
+
| `browser_agent` | Strip injections, allow safe content |
|
|
68
|
+
| `finance_agent` | Strictest — block everything suspicious |
|
|
69
|
+
| `rag_assistant` | Strip injections, preserve safe data |
|
|
70
|
+
|
|
71
|
+
## Related
|
|
72
|
+
|
|
73
|
+
- [Arc Gate](https://github.com/9hannahnine-jpg/arc-gate) — OpenAI-compatible proxy version
|
|
74
|
+
- [arc-sentry](https://github.com/9hannahnine-jpg/arc-sentry) — Whitebox detector for self-hosted models
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
AGPL-3.0. Commercial license available — contact 9hannahnine@gmail.com.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# arc-gate-mcp
|
|
2
|
+
|
|
3
|
+
**Runtime governance for MCP tool calls.**
|
|
4
|
+
|
|
5
|
+
Arc Gate MCP sits between your agent and any MCP server. It intercepts all tool call results and enforces instruction-authority boundaries before the agent processes them.
|
|
6
|
+
|
|
7
|
+
When a tool result contains injected instructions — a poisoned document, a malicious webpage, a hostile database row — Arc Gate blocks them before they reach the agent.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install arc-gate-mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
### Full proxy (wraps any MCP server)
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from arc_gate_mcp import ArcGateMCPProxy
|
|
21
|
+
|
|
22
|
+
proxy = ArcGateMCPProxy(
|
|
23
|
+
upstream_url="http://localhost:8000/sse",
|
|
24
|
+
policy_mode="rag_assistant",
|
|
25
|
+
)
|
|
26
|
+
proxy.run()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Per-tool guard
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from arc_gate_mcp import ArcGateToolGuard
|
|
33
|
+
|
|
34
|
+
guard = ArcGateToolGuard(policy_mode="rag_assistant")
|
|
35
|
+
|
|
36
|
+
@mcp.tool()
|
|
37
|
+
async def read_document(path: str) -> str:
|
|
38
|
+
content = read_file(path)
|
|
39
|
+
return guard.check(content, tool_name="read_document")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### CLI
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
arc-gate-mcp --upstream http://localhost:8000/sse --policy rag_assistant
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Policy modes
|
|
49
|
+
|
|
50
|
+
| Mode | Behavior |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `balanced` | Block on detected injection |
|
|
53
|
+
| `browser_agent` | Strip injections, allow safe content |
|
|
54
|
+
| `finance_agent` | Strictest — block everything suspicious |
|
|
55
|
+
| `rag_assistant` | Strip injections, preserve safe data |
|
|
56
|
+
|
|
57
|
+
## Related
|
|
58
|
+
|
|
59
|
+
- [Arc Gate](https://github.com/9hannahnine-jpg/arc-gate) — OpenAI-compatible proxy version
|
|
60
|
+
- [arc-sentry](https://github.com/9hannahnine-jpg/arc-sentry) — Whitebox detector for self-hosted models
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
AGPL-3.0. Commercial license available — contact 9hannahnine@gmail.com.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Arc Gate MCP — Runtime governance for MCP tool calls.
|
|
3
|
+
"""
|
|
4
|
+
from .arc_gate_mcp import (
|
|
5
|
+
ArcGateMCPProxy,
|
|
6
|
+
ArcGateToolGuard,
|
|
7
|
+
GovernanceDecision,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["ArcGateMCPProxy", "ArcGateToolGuard", "GovernanceDecision"]
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""
|
|
2
|
+
arc-gate-mcp — Runtime governance for MCP tool calls
|
|
3
|
+
=====================================================
|
|
4
|
+
Arc Gate MCP sits between your MCP client (agent) and any MCP server.
|
|
5
|
+
It intercepts all tool call results and enforces instruction-authority
|
|
6
|
+
boundaries before the agent processes them.
|
|
7
|
+
|
|
8
|
+
When a tool result contains injected instructions, Arc Gate blocks them
|
|
9
|
+
before they reach the agent — the same guarantee as the OpenAI proxy,
|
|
10
|
+
but for the MCP protocol layer.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
# Wrap any MCP server
|
|
14
|
+
from arc_gate_mcp import ArcGateMCPProxy
|
|
15
|
+
|
|
16
|
+
proxy = ArcGateMCPProxy(
|
|
17
|
+
upstream_url="http://localhost:8000/sse", # your MCP server
|
|
18
|
+
policy_mode="rag_assistant", # or browser_agent, finance_agent
|
|
19
|
+
arc_gate_url="https://web-production-6e47f.up.railway.app/v1/chat/completions",
|
|
20
|
+
api_key="your-key",
|
|
21
|
+
)
|
|
22
|
+
proxy.run()
|
|
23
|
+
|
|
24
|
+
Architecture:
|
|
25
|
+
Agent → Arc Gate MCP Proxy → Upstream MCP Server
|
|
26
|
+
↓
|
|
27
|
+
Tool result intercepted
|
|
28
|
+
Authority state machine checks source
|
|
29
|
+
If injection detected: result sanitized / blocked
|
|
30
|
+
If clean: result passed through unchanged
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import uuid
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from typing import Any, Optional
|
|
40
|
+
from urllib.parse import urlparse
|
|
41
|
+
|
|
42
|
+
import httpx
|
|
43
|
+
from mcp.server.fastmcp import FastMCP
|
|
44
|
+
from mcp import ClientSession
|
|
45
|
+
from mcp.client.sse import sse_client
|
|
46
|
+
from mcp.types import (
|
|
47
|
+
CallToolResult,
|
|
48
|
+
TextContent,
|
|
49
|
+
Tool,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ── Authority detection (mirrors arc_authority_state.py logic) ────────────────
|
|
53
|
+
|
|
54
|
+
TOOL_POISON_PATTERNS = [
|
|
55
|
+
r"<information>",
|
|
56
|
+
r"</information>",
|
|
57
|
+
r"this\s+is\s+an?\s+important\s+message\s+from\s+me",
|
|
58
|
+
r"before\s+you\s+can\s+solve\s+the\s+task",
|
|
59
|
+
r"please\s+do\s+the\s+following\s+first",
|
|
60
|
+
r"after\s+you\s+do\s+that.{0,30}you\s+can\s+solve",
|
|
61
|
+
r"you\s+should\s+use\s+the\s+tool\s+`?\w+`?",
|
|
62
|
+
r"signed,?\s*\n\s*\w+",
|
|
63
|
+
r"(assistant|agent|ai|llm|model|system|gpt|claude)\s*[,:]?\s*(ignore|disregard|forget|override|bypass|reveal|follow|obey|comply)",
|
|
64
|
+
r"(for\s+ai\s+(readers?|systems?|agents?)|note\s+to\s+(ai|assistant|agent|llm))",
|
|
65
|
+
r"(hidden|invisible|secret)\s+(instruction|command|directive|note)",
|
|
66
|
+
r"(this\s+(tool|document|webpage|email|result|output|database|file))\s+(supersedes?|overrides?|replaces?)",
|
|
67
|
+
r"follow\s+(the\s+)?(instructions?|directive|command)\s+(in|from|inside|within|embedded\s+in)\s+(this|the)",
|
|
68
|
+
r"ignore\s+(all\s+)?(previous|prior|earlier|above|your)\s+instructions?",
|
|
69
|
+
r"disregard\s+(all\s+)?(previous|prior|earlier|above|your)\s+instructions?",
|
|
70
|
+
r"override\s+(system|developer|operator|admin)\s+instructions?",
|
|
71
|
+
r"bypass\s+(safety|security|content|policy|all)\s+(filter|guard|check|rule|restriction)s?",
|
|
72
|
+
r"system\s+(override|command|directive|instruction)",
|
|
73
|
+
r"\[system\]",
|
|
74
|
+
r"### system",
|
|
75
|
+
r"your\s+new\s+instructions?\s+(are|is|follow)",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
_COMPILED_PATTERNS = [
|
|
79
|
+
re.compile(p, re.IGNORECASE | re.DOTALL)
|
|
80
|
+
for p in TOOL_POISON_PATTERNS
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _detect_injection(text: str) -> tuple[bool, Optional[str]]:
|
|
85
|
+
"""Check tool result text for injection attempts. Returns (detected, matched_pattern)."""
|
|
86
|
+
import unicodedata, base64, codecs
|
|
87
|
+
|
|
88
|
+
def _norm(s):
|
|
89
|
+
return unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode('ascii')
|
|
90
|
+
|
|
91
|
+
variants = [text, _norm(text)]
|
|
92
|
+
|
|
93
|
+
# Base64 decode attempt
|
|
94
|
+
for chunk in re.findall(r'[A-Za-z0-9+/]{20,}={0,2}', text):
|
|
95
|
+
try:
|
|
96
|
+
decoded = base64.b64decode(chunk).decode('utf-8', errors='ignore')
|
|
97
|
+
if len(decoded) > 10:
|
|
98
|
+
variants.append(decoded)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
# ROT13
|
|
103
|
+
try:
|
|
104
|
+
variants.append(codecs.decode(text, 'rot13'))
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
for variant in variants:
|
|
109
|
+
for pattern in _COMPILED_PATTERNS:
|
|
110
|
+
m = pattern.search(variant)
|
|
111
|
+
if m:
|
|
112
|
+
return True, m.group(0)[:80]
|
|
113
|
+
|
|
114
|
+
return False, None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class GovernanceDecision:
|
|
119
|
+
blocked: bool
|
|
120
|
+
decision: str # allow | restricted_continue | block
|
|
121
|
+
reason: str
|
|
122
|
+
matched_pattern: Optional[str]
|
|
123
|
+
sanitized_content: Optional[str] = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _govern_tool_result(
|
|
127
|
+
tool_name: str,
|
|
128
|
+
result_text: str,
|
|
129
|
+
policy_mode: str = "balanced",
|
|
130
|
+
) -> GovernanceDecision:
|
|
131
|
+
"""
|
|
132
|
+
Apply Arc Gate governance to a tool result.
|
|
133
|
+
Returns a GovernanceDecision with the enforcement action.
|
|
134
|
+
"""
|
|
135
|
+
detected, matched = _detect_injection(result_text)
|
|
136
|
+
|
|
137
|
+
if not detected:
|
|
138
|
+
return GovernanceDecision(
|
|
139
|
+
blocked=False,
|
|
140
|
+
decision="allow",
|
|
141
|
+
reason="no_violation",
|
|
142
|
+
matched_pattern=None,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Finance agent: strictest — block everything
|
|
146
|
+
if policy_mode == "finance_agent":
|
|
147
|
+
return GovernanceDecision(
|
|
148
|
+
blocked=True,
|
|
149
|
+
decision="block",
|
|
150
|
+
reason="source_boundary_violation",
|
|
151
|
+
matched_pattern=matched,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Browser agent: block tool calls but allow safe text continuation
|
|
155
|
+
if policy_mode == "browser_agent":
|
|
156
|
+
safe = _sanitize_result(result_text)
|
|
157
|
+
return GovernanceDecision(
|
|
158
|
+
blocked=False,
|
|
159
|
+
decision="restricted_continue",
|
|
160
|
+
reason="source_boundary_violation",
|
|
161
|
+
matched_pattern=matched,
|
|
162
|
+
sanitized_content=safe,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# RAG assistant: strip injection, return safe content
|
|
166
|
+
if policy_mode == "rag_assistant":
|
|
167
|
+
safe = _sanitize_result(result_text)
|
|
168
|
+
return GovernanceDecision(
|
|
169
|
+
blocked=False,
|
|
170
|
+
decision="restricted_continue",
|
|
171
|
+
reason="source_boundary_violation",
|
|
172
|
+
matched_pattern=matched,
|
|
173
|
+
sanitized_content=safe,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Balanced default: block
|
|
177
|
+
return GovernanceDecision(
|
|
178
|
+
blocked=True,
|
|
179
|
+
decision="block",
|
|
180
|
+
reason="source_boundary_violation",
|
|
181
|
+
matched_pattern=matched,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _sanitize_result(text: str) -> str:
|
|
186
|
+
"""Strip injection content from tool result, preserving safe data."""
|
|
187
|
+
# Remove <INFORMATION>...</INFORMATION> blocks
|
|
188
|
+
text = re.sub(
|
|
189
|
+
r'<INFORMATION>.*?</INFORMATION>',
|
|
190
|
+
'[Arc Gate: Injected instruction removed]',
|
|
191
|
+
text, flags=re.IGNORECASE | re.DOTALL
|
|
192
|
+
)
|
|
193
|
+
# Remove lines that match injection patterns
|
|
194
|
+
lines = []
|
|
195
|
+
for line in text.split('\n'):
|
|
196
|
+
detected, _ = _detect_injection(line)
|
|
197
|
+
if not detected:
|
|
198
|
+
lines.append(line)
|
|
199
|
+
else:
|
|
200
|
+
lines.append('[Arc Gate: Instruction removed]')
|
|
201
|
+
return '\n'.join(lines)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Arc Gate MCP Proxy ────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
class ArcGateMCPProxy:
|
|
207
|
+
"""
|
|
208
|
+
Transparent MCP proxy that enforces instruction-authority boundaries
|
|
209
|
+
on all tool call results before they reach the agent.
|
|
210
|
+
|
|
211
|
+
Wraps any upstream MCP server and exposes the same tool interface.
|
|
212
|
+
Injected instructions in tool results are blocked or sanitized
|
|
213
|
+
depending on the policy mode.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
upstream_url: str,
|
|
219
|
+
policy_mode: str = "balanced",
|
|
220
|
+
arc_gate_url: Optional[str] = None,
|
|
221
|
+
api_key: Optional[str] = None,
|
|
222
|
+
server_name: str = "arc-gate-mcp",
|
|
223
|
+
):
|
|
224
|
+
self.upstream_url = upstream_url
|
|
225
|
+
self.policy_mode = policy_mode
|
|
226
|
+
self.arc_gate_url = arc_gate_url or os.environ.get(
|
|
227
|
+
"ARC_GATE_URL",
|
|
228
|
+
"https://web-production-6e47f.up.railway.app/v1/chat/completions"
|
|
229
|
+
)
|
|
230
|
+
self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "demo")
|
|
231
|
+
self.server_name = server_name
|
|
232
|
+
self.mcp = FastMCP(server_name)
|
|
233
|
+
self._upstream_tools: list[Tool] = []
|
|
234
|
+
self._session_id = f"mcp_proxy_{uuid.uuid4().hex[:12]}"
|
|
235
|
+
self._blocked_count = 0
|
|
236
|
+
self._allowed_count = 0
|
|
237
|
+
|
|
238
|
+
async def _fetch_upstream_tools(self) -> list[Tool]:
|
|
239
|
+
"""Connect to upstream MCP server and discover available tools."""
|
|
240
|
+
async with sse_client(self.upstream_url) as (read, write):
|
|
241
|
+
async with ClientSession(read, write) as session:
|
|
242
|
+
await session.initialize()
|
|
243
|
+
result = await session.list_tools()
|
|
244
|
+
return result.tools
|
|
245
|
+
|
|
246
|
+
async def _call_upstream_tool(
|
|
247
|
+
self, tool_name: str, arguments: dict
|
|
248
|
+
) -> CallToolResult:
|
|
249
|
+
"""Call a tool on the upstream MCP server."""
|
|
250
|
+
async with sse_client(self.upstream_url) as (read, write):
|
|
251
|
+
async with ClientSession(read, write) as session:
|
|
252
|
+
await session.initialize()
|
|
253
|
+
return await session.call_tool(tool_name, arguments)
|
|
254
|
+
|
|
255
|
+
def _make_blocked_result(self, tool_name: str, matched: str) -> CallToolResult:
|
|
256
|
+
"""Return a safe blocked result with Arc Gate metadata."""
|
|
257
|
+
return CallToolResult(
|
|
258
|
+
content=[TextContent(
|
|
259
|
+
type="text",
|
|
260
|
+
text=(
|
|
261
|
+
f"[Arc Gate] Tool result from '{tool_name}' was blocked.\n"
|
|
262
|
+
f"Reason: Untrusted content attempted instruction-authority transfer.\n"
|
|
263
|
+
f"Matched pattern: {matched}\n"
|
|
264
|
+
f"Session: {self._session_id}\n"
|
|
265
|
+
f"Policy: {self.policy_mode}\n\n"
|
|
266
|
+
f"The tool call completed but the result contained injected instructions "
|
|
267
|
+
f"that were prevented from reaching the agent."
|
|
268
|
+
)
|
|
269
|
+
)],
|
|
270
|
+
isError=False,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def _make_restricted_result(
|
|
274
|
+
self, tool_name: str, safe_content: str, matched: str
|
|
275
|
+
) -> CallToolResult:
|
|
276
|
+
"""Return sanitized result with Arc Gate warning."""
|
|
277
|
+
return CallToolResult(
|
|
278
|
+
content=[TextContent(
|
|
279
|
+
type="text",
|
|
280
|
+
text=(
|
|
281
|
+
f"[Arc Gate: RESTRICTED_CONTINUE] Injected instructions removed from "
|
|
282
|
+
f"'{tool_name}' result. Safe content preserved.\n\n"
|
|
283
|
+
f"{safe_content}"
|
|
284
|
+
)
|
|
285
|
+
)],
|
|
286
|
+
isError=False,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
async def _governed_tool_call(
|
|
290
|
+
self, tool_name: str, arguments: dict
|
|
291
|
+
) -> CallToolResult:
|
|
292
|
+
"""Call upstream tool and apply governance to the result."""
|
|
293
|
+
# Call upstream
|
|
294
|
+
result = await self._call_upstream_tool(tool_name, arguments)
|
|
295
|
+
|
|
296
|
+
# Extract text content for inspection
|
|
297
|
+
full_text = "\n".join(
|
|
298
|
+
block.text for block in result.content
|
|
299
|
+
if hasattr(block, "text")
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# Apply governance
|
|
303
|
+
decision = _govern_tool_result(full_text, tool_name, self.policy_mode)
|
|
304
|
+
|
|
305
|
+
if decision.blocked:
|
|
306
|
+
self._blocked_count += 1
|
|
307
|
+
print(
|
|
308
|
+
f"[Arc Gate] BLOCKED tool='{tool_name}' "
|
|
309
|
+
f"pattern='{decision.matched_pattern}' "
|
|
310
|
+
f"session={self._session_id[:16]}"
|
|
311
|
+
)
|
|
312
|
+
return self._make_blocked_result(tool_name, decision.matched_pattern or "")
|
|
313
|
+
|
|
314
|
+
if decision.decision == "restricted_continue":
|
|
315
|
+
self._blocked_count += 1
|
|
316
|
+
print(
|
|
317
|
+
f"[Arc Gate] RESTRICTED_CONTINUE tool='{tool_name}' "
|
|
318
|
+
f"pattern='{decision.matched_pattern}' "
|
|
319
|
+
f"session={self._session_id[:16]}"
|
|
320
|
+
)
|
|
321
|
+
return self._make_restricted_result(
|
|
322
|
+
tool_name,
|
|
323
|
+
decision.sanitized_content or full_text,
|
|
324
|
+
decision.matched_pattern or "",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
self._allowed_count += 1
|
|
328
|
+
return result
|
|
329
|
+
|
|
330
|
+
async def _setup(self):
|
|
331
|
+
"""Discover upstream tools and register governed versions."""
|
|
332
|
+
print(f"[Arc Gate MCP] Connecting to upstream: {self.upstream_url}")
|
|
333
|
+
tools = await self._fetch_upstream_tools()
|
|
334
|
+
self._upstream_tools = tools
|
|
335
|
+
print(f"[Arc Gate MCP] Discovered {len(tools)} tools: {[t.name for t in tools]}")
|
|
336
|
+
|
|
337
|
+
for tool in tools:
|
|
338
|
+
# Capture tool in closure
|
|
339
|
+
tool_name = tool.name
|
|
340
|
+
|
|
341
|
+
async def make_handler(name: str):
|
|
342
|
+
async def handler(**kwargs) -> str:
|
|
343
|
+
result = await self._governed_tool_call(name, kwargs)
|
|
344
|
+
texts = [
|
|
345
|
+
block.text for block in result.content
|
|
346
|
+
if hasattr(block, "text")
|
|
347
|
+
]
|
|
348
|
+
return "\n".join(texts)
|
|
349
|
+
return handler
|
|
350
|
+
|
|
351
|
+
handler = await make_handler(tool_name)
|
|
352
|
+
handler.__doc__ = (
|
|
353
|
+
f"{tool.description or tool_name}\n\n"
|
|
354
|
+
f"[Protected by Arc Gate — policy: {self.policy_mode}]"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
self.mcp.add_tool(
|
|
358
|
+
handler,
|
|
359
|
+
name=tool_name,
|
|
360
|
+
description=handler.__doc__,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
print(f"[Arc Gate MCP] Proxy ready. Policy: {self.policy_mode}")
|
|
364
|
+
print(f"[Arc Gate MCP] Session: {self._session_id}")
|
|
365
|
+
|
|
366
|
+
def run(self, transport: str = "stdio"):
|
|
367
|
+
"""Start the Arc Gate MCP proxy server."""
|
|
368
|
+
async def _run():
|
|
369
|
+
await self._setup()
|
|
370
|
+
if transport == "sse":
|
|
371
|
+
await self.mcp.run_sse_async()
|
|
372
|
+
else:
|
|
373
|
+
await self.mcp.run_stdio_async()
|
|
374
|
+
|
|
375
|
+
asyncio.run(_run())
|
|
376
|
+
|
|
377
|
+
def stats(self) -> dict:
|
|
378
|
+
return {
|
|
379
|
+
"session_id": self._session_id,
|
|
380
|
+
"policy_mode": self.policy_mode,
|
|
381
|
+
"upstream_url": self.upstream_url,
|
|
382
|
+
"tools_proxied": len(self._upstream_tools),
|
|
383
|
+
"blocked": self._blocked_count,
|
|
384
|
+
"allowed": self._allowed_count,
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ── Standalone governance checker (no upstream required) ─────────────────────
|
|
389
|
+
|
|
390
|
+
class ArcGateToolGuard:
|
|
391
|
+
"""
|
|
392
|
+
Lightweight tool result governance for use without a full MCP proxy.
|
|
393
|
+
Drop into any existing MCP tool handler to protect individual tools.
|
|
394
|
+
|
|
395
|
+
Usage:
|
|
396
|
+
from arc_gate_mcp import ArcGateToolGuard
|
|
397
|
+
|
|
398
|
+
guard = ArcGateToolGuard(policy_mode="rag_assistant")
|
|
399
|
+
|
|
400
|
+
@mcp.tool()
|
|
401
|
+
async def read_document(path: str) -> str:
|
|
402
|
+
content = read_file(path)
|
|
403
|
+
return guard.check(content, tool_name="read_document")
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
def __init__(self, policy_mode: str = "balanced"):
|
|
407
|
+
self.policy_mode = policy_mode
|
|
408
|
+
self.blocked_count = 0
|
|
409
|
+
self.allowed_count = 0
|
|
410
|
+
|
|
411
|
+
def check(self, result: str, tool_name: str = "tool") -> str:
|
|
412
|
+
"""
|
|
413
|
+
Check a tool result and return safe content.
|
|
414
|
+
Raises ValueError if blocked, returns sanitized content if restricted.
|
|
415
|
+
"""
|
|
416
|
+
decision = _govern_tool_result(tool_name, result, self.policy_mode)
|
|
417
|
+
|
|
418
|
+
if decision.blocked:
|
|
419
|
+
self.blocked_count += 1
|
|
420
|
+
raise ValueError(
|
|
421
|
+
f"[Arc Gate] Tool result blocked — "
|
|
422
|
+
f"instruction-authority transfer detected in '{tool_name}'. "
|
|
423
|
+
f"Pattern: {decision.matched_pattern}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if decision.decision == "restricted_continue":
|
|
427
|
+
self.blocked_count += 1
|
|
428
|
+
return (
|
|
429
|
+
f"[Arc Gate: Injected instructions removed]\n\n"
|
|
430
|
+
f"{decision.sanitized_content or result}"
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
self.allowed_count += 1
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ── CLI entrypoint ────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
def main():
|
|
440
|
+
import argparse
|
|
441
|
+
|
|
442
|
+
parser = argparse.ArgumentParser(
|
|
443
|
+
description="Arc Gate MCP — Runtime governance for MCP tool calls"
|
|
444
|
+
)
|
|
445
|
+
parser.add_argument(
|
|
446
|
+
"--upstream", required=True,
|
|
447
|
+
help="Upstream MCP server URL (e.g. http://localhost:8000/sse)"
|
|
448
|
+
)
|
|
449
|
+
parser.add_argument(
|
|
450
|
+
"--policy", default="balanced",
|
|
451
|
+
choices=["balanced", "browser_agent", "finance_agent", "rag_assistant", "strict"],
|
|
452
|
+
help="Policy mode (default: balanced)"
|
|
453
|
+
)
|
|
454
|
+
parser.add_argument(
|
|
455
|
+
"--transport", default="stdio",
|
|
456
|
+
choices=["stdio", "sse"],
|
|
457
|
+
help="Transport (default: stdio)"
|
|
458
|
+
)
|
|
459
|
+
parser.add_argument(
|
|
460
|
+
"--api-key", default=None,
|
|
461
|
+
help="Arc Gate API key (or set OPENAI_API_KEY env var)"
|
|
462
|
+
)
|
|
463
|
+
args = parser.parse_args()
|
|
464
|
+
|
|
465
|
+
proxy = ArcGateMCPProxy(
|
|
466
|
+
upstream_url=args.upstream,
|
|
467
|
+
policy_mode=args.policy,
|
|
468
|
+
api_key=args.api_key,
|
|
469
|
+
)
|
|
470
|
+
proxy.run(transport=args.transport)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
if __name__ == "__main__":
|
|
474
|
+
main()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arc-gate-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime governance for MCP tool calls — Arc Gate for the MCP protocol layer
|
|
5
|
+
Author-email: Hannah Nine <9hannahnine@gmail.com>
|
|
6
|
+
License: AGPL-3.0
|
|
7
|
+
Project-URL: Homepage, https://bendexgeometry.com/gate
|
|
8
|
+
Project-URL: Repository, https://github.com/9hannahnine-jpg/arc-gate-mcp
|
|
9
|
+
Keywords: mcp,prompt-injection,ai-security,llm,agent,runtime-governance
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: mcp>=1.0.0
|
|
13
|
+
Requires-Dist: httpx>=0.25.0
|
|
14
|
+
|
|
15
|
+
# arc-gate-mcp
|
|
16
|
+
|
|
17
|
+
**Runtime governance for MCP tool calls.**
|
|
18
|
+
|
|
19
|
+
Arc Gate MCP sits between your agent and any MCP server. It intercepts all tool call results and enforces instruction-authority boundaries before the agent processes them.
|
|
20
|
+
|
|
21
|
+
When a tool result contains injected instructions — a poisoned document, a malicious webpage, a hostile database row — Arc Gate blocks them before they reach the agent.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install arc-gate-mcp
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
### Full proxy (wraps any MCP server)
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from arc_gate_mcp import ArcGateMCPProxy
|
|
35
|
+
|
|
36
|
+
proxy = ArcGateMCPProxy(
|
|
37
|
+
upstream_url="http://localhost:8000/sse",
|
|
38
|
+
policy_mode="rag_assistant",
|
|
39
|
+
)
|
|
40
|
+
proxy.run()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Per-tool guard
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from arc_gate_mcp import ArcGateToolGuard
|
|
47
|
+
|
|
48
|
+
guard = ArcGateToolGuard(policy_mode="rag_assistant")
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
async def read_document(path: str) -> str:
|
|
52
|
+
content = read_file(path)
|
|
53
|
+
return guard.check(content, tool_name="read_document")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### CLI
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
arc-gate-mcp --upstream http://localhost:8000/sse --policy rag_assistant
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Policy modes
|
|
63
|
+
|
|
64
|
+
| Mode | Behavior |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `balanced` | Block on detected injection |
|
|
67
|
+
| `browser_agent` | Strip injections, allow safe content |
|
|
68
|
+
| `finance_agent` | Strictest — block everything suspicious |
|
|
69
|
+
| `rag_assistant` | Strip injections, preserve safe data |
|
|
70
|
+
|
|
71
|
+
## Related
|
|
72
|
+
|
|
73
|
+
- [Arc Gate](https://github.com/9hannahnine-jpg/arc-gate) — OpenAI-compatible proxy version
|
|
74
|
+
- [arc-sentry](https://github.com/9hannahnine-jpg/arc-sentry) — Whitebox detector for self-hosted models
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
AGPL-3.0. Commercial license available — contact 9hannahnine@gmail.com.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
arc_gate_mcp/__init__.py
|
|
4
|
+
arc_gate_mcp/arc_gate_mcp.py
|
|
5
|
+
arc_gate_mcp.egg-info/PKG-INFO
|
|
6
|
+
arc_gate_mcp.egg-info/SOURCES.txt
|
|
7
|
+
arc_gate_mcp.egg-info/dependency_links.txt
|
|
8
|
+
arc_gate_mcp.egg-info/entry_points.txt
|
|
9
|
+
arc_gate_mcp.egg-info/requires.txt
|
|
10
|
+
arc_gate_mcp.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
arc_gate_mcp
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "arc-gate-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Runtime governance for MCP tool calls — Arc Gate for the MCP protocol layer"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "AGPL-3.0" }
|
|
11
|
+
authors = [{ name = "Hannah Nine", email = "9hannahnine@gmail.com" }]
|
|
12
|
+
keywords = ["mcp", "prompt-injection", "ai-security", "llm", "agent", "runtime-governance"]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
dependencies = ["mcp>=1.0.0", "httpx>=0.25.0"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
arc-gate-mcp = "arc_gate_mcp.arc_gate_mcp:main"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://bendexgeometry.com/gate"
|
|
21
|
+
Repository = "https://github.com/9hannahnine-jpg/arc-gate-mcp"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["."]
|
|
25
|
+
include = ["arc_gate_mcp*"]
|