@7h3/protocol 0.1.2 → 0.4.0
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.
- package/.dockerignore +19 -0
- package/.github/dependabot.yml +32 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/publish.yml +59 -0
- package/.github/workflows/scorecard.yml +37 -0
- package/7h3.example.yaml +125 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +82 -0
- package/Dockerfile +73 -0
- package/GOVERNANCE.md +62 -0
- package/README.md +323 -6
- package/SECURITY.md +70 -0
- package/bench-results/replay-cache-full-1777891033256.json +10 -0
- package/bench-results/replay-cache-full-1777896317488.json +10 -0
- package/bench-results/replay-cache-full-1777900993184.json +10 -0
- package/bench-results/replay-cache-full-1777901019285.json +10 -0
- package/bench-results/replay-cache-quick-1777870170126.json +10 -0
- package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
- package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
- package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
- package/bench-results/wire-codecs-full-1777891019803.json +93 -0
- package/bench-results/wire-codecs-full-1777896260964.json +93 -0
- package/bench-results/wire-codecs-full-1777901004247.json +93 -0
- package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
- package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
- package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
- package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
- package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
- package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
- package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
- package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
- package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
- package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
- package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
- package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
- package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
- package/bin/7h3.ts +385 -0
- package/conformance/7h3_v0_1.json +77 -0
- package/conformance/7h3_v0_1_binary.json +20 -0
- package/conformance/aip_v0_1_binary.json +20 -0
- package/docker-compose.yaml +77 -0
- package/docs/ADOPTION_PLAN.md +120 -0
- package/docs/AGENTS.md +77 -0
- package/docs/AIP_RFC_v0.1.md +97 -0
- package/docs/AI_DECISION_CARD.md +122 -0
- package/docs/AI_RUNTIME_POLICY.json +126 -0
- package/docs/AI_RUNTIME_POLICY.yaml +110 -0
- package/docs/BACKPRESSURE_TUNING.md +65 -0
- package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
- package/docs/BINARY_CODEC_BENCH.md +23 -0
- package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
- package/docs/CLOCK_SKEW_POLICY.md +30 -0
- package/docs/DISTRIBUTED_REPLAY.md +142 -0
- package/docs/FUZZ_CAMPAIGN.md +121 -0
- package/docs/GATEWAY.md +195 -0
- package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
- package/docs/KEY_REVOCATION.md +69 -0
- package/docs/MCP_WRAPPER.md +159 -0
- package/docs/MIGRATION_GUIDE.md +40 -0
- package/docs/OPERATORS.md +184 -0
- package/docs/PERF_REGRESSION_POLICY.md +34 -0
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
- package/docs/RELEASE_GATE.md +25 -0
- package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
- package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
- package/docs/TELEMETRY.md +41 -0
- package/docs/THREAT_MODEL.md +89 -0
- package/docs/VERSIONING_POLICY.md +30 -0
- package/docs/assets/banner.png +0 -0
- package/eslint.config.js +15 -0
- package/fuzz/ts/harness-decode.ts +136 -0
- package/fuzz/ts/harness-verify.ts +121 -0
- package/fuzz/ts/run.ts +35 -0
- package/mcp-server/README.md +38 -0
- package/mcp-server/package-lock.json +1187 -0
- package/mcp-server/package.json +35 -0
- package/mcp-server/src/index.ts +236 -0
- package/mcp-server/tsconfig.json +14 -0
- package/package.json +79 -13
- package/scripts/aip-framework-quickstart.ts +110 -0
- package/scripts/aip-mcp-gateway.ts +38 -0
- package/scripts/aip-mcp-wrap-demo.ts +72 -0
- package/scripts/aip-quickstart.ts +60 -0
- package/scripts/bench-diff.ts +118 -0
- package/scripts/bench-protocol-e2e.ts +937 -0
- package/scripts/bench-protocol-openloop.ts +1397 -0
- package/scripts/bench-replay-cache.ts +76 -0
- package/scripts/bench-signature-profiles.ts +180 -0
- package/scripts/bench-wire-codecs.ts +161 -0
- package/scripts/build-binary-conformance.ts +36 -0
- package/scripts/build-release-dashboard.ts +175 -0
- package/scripts/canary-rollout.ts +38 -0
- package/scripts/mcpGatewayCli.test.ts +116 -0
- package/scripts/prepare-aip-package.ts +88 -0
- package/scripts/regen-conformance-sigs.ts +18 -0
- package/scripts/release-gate.ts +19 -0
- package/scripts/validate-runtime-policy.ts +18 -0
- package/sdk/browser/index.test.ts +162 -0
- package/sdk/browser/index.ts +257 -0
- package/sdk/browser/package.json +13 -0
- package/sdk/go/go.mod +3 -0
- package/sdk/go/http.go +135 -0
- package/sdk/go/protocol.go +324 -0
- package/sdk/go/protocol_test.go +334 -0
- package/sdk/go/webhook.go +136 -0
- package/sdk/python/README.md +18 -0
- package/sdk/python/protocol_7h3/__init__.py +46 -0
- package/sdk/python/protocol_7h3/http.py +212 -0
- package/sdk/python/protocol_7h3/keys.py +149 -0
- package/sdk/python/protocol_7h3/protocol.py +525 -0
- package/sdk/python/protocol_7h3/queue.py +118 -0
- package/sdk/python/protocol_7h3/webhook.py +116 -0
- package/sdk/python/pyproject.toml +40 -0
- package/sdk/python/tests/test_conformance.py +110 -0
- package/sdk/python/tests/test_http.py +305 -0
- package/sdk/python/tests/test_keys.py +417 -0
- package/sdk/python/tests/test_queue.py +120 -0
- package/sdk/python/tests/test_webhook.py +345 -0
- package/sdk/rust/Cargo.lock +371 -0
- package/sdk/rust/Cargo.toml +25 -0
- package/sdk/rust/README.md +31 -0
- package/sdk/rust/fuzz/Cargo.toml +29 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
- package/sdk/rust/src/http.rs +145 -0
- package/sdk/rust/src/keys.rs +161 -0
- package/sdk/rust/src/lib.rs +688 -0
- package/sdk/rust/src/queue.rs +79 -0
- package/sdk/rust/src/webhook.rs +86 -0
- package/sdk/rust/tests/conformance.rs +148 -0
- package/sdk/rust/tests/gateway.rs +130 -0
- package/sdk/rust/tests/http_webhook_queue.rs +201 -0
- package/sdk/rust/tests/keys.rs +189 -0
- package/src/agentAdapter.test.ts +48 -0
- package/src/agentAdapter.ts +56 -0
- package/src/auditLog.test.ts +145 -0
- package/src/auditLog.ts +147 -0
- package/src/conformance.test.ts +136 -0
- package/src/conformanceVectors.ts +99 -0
- package/src/frameworkAdapters.test.ts +290 -0
- package/src/frameworkAdapters.ts +261 -0
- package/src/gateway.test.ts +343 -0
- package/src/gateway.ts +171 -0
- package/src/grpcBinding.test.ts +211 -0
- package/src/grpcBinding.ts +103 -0
- package/src/httpBinding.test.ts +376 -0
- package/src/httpBinding.ts +163 -0
- package/src/index.ts +32 -0
- package/src/keyInfra.test.ts +278 -0
- package/src/keyInfra.ts +228 -0
- package/src/keyRegistry.ts +59 -0
- package/src/keyRotation.test.ts +78 -0
- package/src/keyRotation.ts +72 -0
- package/src/mcpGateway.test.ts +129 -0
- package/src/mcpGateway.ts +250 -0
- package/src/mcpTransports.test.ts +92 -0
- package/src/mcpTransports.ts +169 -0
- package/src/mcpWrapper.test.ts +179 -0
- package/src/mcpWrapper.ts +206 -0
- package/src/policyEnforcer.test.ts +99 -0
- package/src/policyEnforcer.ts +169 -0
- package/src/policyTelemetryFeedback.test.ts +25 -0
- package/src/policyTelemetryFeedback.ts +38 -0
- package/src/protocol.bench.ts +37 -0
- package/src/protocol.test.ts +155 -0
- package/src/protocol.ts +413 -0
- package/src/protocolAgent.test.ts +105 -0
- package/src/protocolAgent.ts +169 -0
- package/src/protocolBinary.test.ts +165 -0
- package/src/protocolBinary.ts +312 -0
- package/src/protocolCapabilities.ts +70 -0
- package/src/protocolFuzz.advanced.test.ts +235 -0
- package/src/protocolFuzz.test.ts +111 -0
- package/src/protocolNegative.test.ts +97 -0
- package/src/protocolReplay.test.ts +71 -0
- package/src/protocolReplay.ts +194 -0
- package/src/protocolTransport.test.ts +556 -0
- package/src/protocolTransport.ts +483 -0
- package/src/queueBinding.test.ts +130 -0
- package/src/queueBinding.ts +102 -0
- package/src/rateLimiter.test.ts +96 -0
- package/src/rateLimiter.ts +46 -0
- package/src/redisClient.ts +140 -0
- package/src/redisIntegration.test.ts +134 -0
- package/src/replayStores.test.ts +141 -0
- package/src/replayStores.ts +82 -0
- package/src/revocation.test.ts +98 -0
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +87 -0
- package/src/routePolicy.ts +72 -0
- package/src/runtimePolicy.test.ts +49 -0
- package/src/runtimePolicy.ts +81 -0
- package/src/runtimePolicyManager.test.ts +29 -0
- package/src/runtimePolicyManager.ts +50 -0
- package/src/runtimePolicyPresets.ts +43 -0
- package/src/signedResponse.test.ts +111 -0
- package/src/signedResponse.ts +83 -0
- package/src/webhookBinding.test.ts +144 -0
- package/src/webhookBinding.ts +115 -0
- package/src/wsBinding.test.ts +221 -0
- package/src/wsBinding.ts +100 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +23 -0
- package/vite.lib.config.ts +16 -0
- package/agentAdapter.d.ts +0 -26
- package/conformanceVectors.d.ts +0 -20
- package/frameworkAdapters.d.ts +0 -72
- package/index.d.ts +0 -20
- package/index.js +0 -1702
- package/keyRotation.d.ts +0 -20
- package/mcpGateway.d.ts +0 -37
- package/mcpTransports.d.ts +0 -62
- package/mcpWrapper.d.ts +0 -83
- package/policyEnforcer.d.ts +0 -50
- package/policyTelemetryFeedback.d.ts +0 -11
- package/protocol.d.ts +0 -66
- package/protocolAgent.d.ts +0 -58
- package/protocolBinary.d.ts +0 -8
- package/protocolCapabilities.d.ts +0 -24
- package/protocolReplay.d.ts +0 -35
- package/protocolTransport.d.ts +0 -73
- package/redisClient.d.ts +0 -49
- package/replayStores.d.ts +0 -32
- package/revocation.d.ts +0 -71
- package/runtimePolicy.d.ts +0 -24
- package/runtimePolicyManager.d.ts +0 -15
- package/runtimePolicyPresets.d.ts +0 -11
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ProtocolEnvelope, SignatureVerificationMaterial } from './protocol'
|
|
2
|
+
|
|
3
|
+
export type KeyStatus = 'active' | 'verify-only' | 'revoked'
|
|
4
|
+
|
|
5
|
+
export interface KeyRecord {
|
|
6
|
+
sender: string
|
|
7
|
+
keyId: string
|
|
8
|
+
alg: SignatureVerificationMaterial['alg']
|
|
9
|
+
status: KeyStatus
|
|
10
|
+
notBeforeMs: number
|
|
11
|
+
notAfterMs: number
|
|
12
|
+
material: SignatureVerificationMaterial
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class RollingKeyring {
|
|
16
|
+
private readonly records: KeyRecord[]
|
|
17
|
+
|
|
18
|
+
constructor(records: KeyRecord[] = []) {
|
|
19
|
+
this.records = [...records]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
add(record: KeyRecord): void {
|
|
23
|
+
this.records.push(record)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
revoke(sender: string, keyId: string, revokedAtMs = Date.now()): boolean {
|
|
27
|
+
const record = this.records.find((entry) => entry.sender === sender && entry.keyId === keyId)
|
|
28
|
+
if (!record) return false
|
|
29
|
+
record.status = 'revoked'
|
|
30
|
+
record.notAfterMs = Math.min(record.notAfterMs, revokedAtMs)
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
selectSigningKey(sender: string, alg: SignatureVerificationMaterial['alg'], nowMs = Date.now()): KeyRecord | null {
|
|
35
|
+
const candidates = this.records
|
|
36
|
+
.filter(
|
|
37
|
+
(entry) =>
|
|
38
|
+
entry.sender === sender &&
|
|
39
|
+
entry.alg === alg &&
|
|
40
|
+
entry.status === 'active' &&
|
|
41
|
+
entry.notBeforeMs <= nowMs &&
|
|
42
|
+
nowMs < entry.notAfterMs,
|
|
43
|
+
)
|
|
44
|
+
.sort((a, b) => b.notBeforeMs - a.notBeforeMs)
|
|
45
|
+
return candidates[0] ?? null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
resolveVerificationMaterial(
|
|
49
|
+
signature: NonNullable<ProtocolEnvelope['signature']>,
|
|
50
|
+
sender: string,
|
|
51
|
+
nowMs = Date.now(),
|
|
52
|
+
): SignatureVerificationMaterial | undefined {
|
|
53
|
+
const candidates = this.records
|
|
54
|
+
.filter(
|
|
55
|
+
(entry) =>
|
|
56
|
+
entry.sender === sender &&
|
|
57
|
+
entry.keyId === signature.keyId &&
|
|
58
|
+
entry.alg === signature.alg &&
|
|
59
|
+
entry.status !== 'revoked' &&
|
|
60
|
+
entry.notBeforeMs <= nowMs &&
|
|
61
|
+
nowMs < entry.notAfterMs,
|
|
62
|
+
)
|
|
63
|
+
.sort((a, b) => b.notBeforeMs - a.notBeforeMs)
|
|
64
|
+
|
|
65
|
+
return candidates[0]?.material
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createKeyringSignatureResolver(keyring: RollingKeyring, nowMsProvider: () => number = () => Date.now()) {
|
|
70
|
+
return async (signature: NonNullable<ProtocolEnvelope['signature']>, sender: string): Promise<SignatureVerificationMaterial | undefined> =>
|
|
71
|
+
keyring.resolveVerificationMaterial(signature, sender, nowMsProvider())
|
|
72
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createAipMcpGatewayRuntime } from './mcpGateway'
|
|
3
|
+
|
|
4
|
+
describe('AIP MCP gateway runtime', () => {
|
|
5
|
+
it('returns JSON-RPC result for allowed method', async () => {
|
|
6
|
+
const runtime = createAipMcpGatewayRuntime({ sharedSecret: 'gateway-test-secret' })
|
|
7
|
+
|
|
8
|
+
const line = JSON.stringify({
|
|
9
|
+
jsonrpc: '2.0',
|
|
10
|
+
id: 1,
|
|
11
|
+
method: 'tools/call',
|
|
12
|
+
params: { name: 'planner' },
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const responseLine = await runtime.handleLine(line)
|
|
16
|
+
expect(responseLine).not.toBeNull()
|
|
17
|
+
const response = JSON.parse(responseLine ?? '{}') as {
|
|
18
|
+
jsonrpc: string
|
|
19
|
+
id: number
|
|
20
|
+
result?: { ok: boolean; capability: string }
|
|
21
|
+
error?: { code: number; message: string }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect(response.jsonrpc).toBe('2.0')
|
|
25
|
+
expect(response.id).toBe(1)
|
|
26
|
+
expect(response.error).toBeUndefined()
|
|
27
|
+
expect(response.result?.ok).toBe(true)
|
|
28
|
+
expect(response.result?.capability).toBe('mcp.tools/call')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns invalid request for malformed JSON-RPC line', async () => {
|
|
32
|
+
const runtime = createAipMcpGatewayRuntime({ sharedSecret: 'gateway-test-secret' })
|
|
33
|
+
const responseLine = await runtime.handleLine('{"bad": true}')
|
|
34
|
+
const response = JSON.parse(responseLine ?? '{}') as { error?: { code: number; message: string } }
|
|
35
|
+
|
|
36
|
+
expect(response.error?.code).toBe(-32600)
|
|
37
|
+
expect(response.error?.message).toBe('Invalid Request')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns method not found for disallowed method', async () => {
|
|
41
|
+
const runtime = createAipMcpGatewayRuntime({
|
|
42
|
+
sharedSecret: 'gateway-test-secret',
|
|
43
|
+
allowedMethods: ['tools/call'],
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const line = JSON.stringify({
|
|
47
|
+
jsonrpc: '2.0',
|
|
48
|
+
id: 'x',
|
|
49
|
+
method: 'resources/read',
|
|
50
|
+
params: {},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const responseLine = await runtime.handleLine(line)
|
|
54
|
+
const response = JSON.parse(responseLine ?? '{}') as { error?: { code: number; message: string } }
|
|
55
|
+
|
|
56
|
+
expect(response.error?.code).toBe(-32601)
|
|
57
|
+
expect(response.error?.message).toContain("is not allowed")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns authorization error when policy hook blocks request', async () => {
|
|
61
|
+
const runtime = createAipMcpGatewayRuntime({
|
|
62
|
+
sharedSecret: 'gateway-test-secret',
|
|
63
|
+
authorizeMethod: async () => false,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const line = JSON.stringify({
|
|
67
|
+
jsonrpc: '2.0',
|
|
68
|
+
id: 'auth-2',
|
|
69
|
+
method: 'tools/call',
|
|
70
|
+
params: {},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const responseLine = await runtime.handleLine(line)
|
|
74
|
+
const response = JSON.parse(responseLine ?? '{}') as { error?: { code: number; message: string } }
|
|
75
|
+
|
|
76
|
+
expect(response.error?.code).toBe(-32001)
|
|
77
|
+
expect(response.error?.message).toContain('not authorized')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns rate-limit error when limiter blocks request', async () => {
|
|
81
|
+
const runtime = createAipMcpGatewayRuntime({
|
|
82
|
+
sharedSecret: 'gateway-test-secret',
|
|
83
|
+
rateLimiter: {
|
|
84
|
+
consume: async () => false,
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const line = JSON.stringify({
|
|
89
|
+
jsonrpc: '2.0',
|
|
90
|
+
id: 'rl-3',
|
|
91
|
+
method: 'tools/call',
|
|
92
|
+
params: {},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const responseLine = await runtime.handleLine(line)
|
|
96
|
+
const response = JSON.parse(responseLine ?? '{}') as { error?: { code: number; message: string } }
|
|
97
|
+
|
|
98
|
+
expect(response.error?.code).toBe(-32002)
|
|
99
|
+
expect(response.error?.message).toContain('rate-limited')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('ignores empty lines', async () => {
|
|
103
|
+
const runtime = createAipMcpGatewayRuntime({ sharedSecret: 'gateway-test-secret' })
|
|
104
|
+
await expect(runtime.handleLine(' ')).resolves.toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('emits audit events for policy and request phases', async () => {
|
|
108
|
+
const phases: string[] = []
|
|
109
|
+
const runtime = createAipMcpGatewayRuntime({
|
|
110
|
+
sharedSecret: 'gateway-test-secret',
|
|
111
|
+
onAuditEvent: async (event) => {
|
|
112
|
+
phases.push(event.phase)
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const line = JSON.stringify({
|
|
117
|
+
jsonrpc: '2.0',
|
|
118
|
+
id: 'audit-1',
|
|
119
|
+
method: 'tools/call',
|
|
120
|
+
params: { name: 'planner' },
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const responseLine = await runtime.handleLine(line)
|
|
124
|
+
expect(responseLine).not.toBeNull()
|
|
125
|
+
expect(phases).toContain('request_received')
|
|
126
|
+
expect(phases).toContain('policy')
|
|
127
|
+
expect(phases).toContain('request_success')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { createAipAgentAdapter } from './agentAdapter'
|
|
2
|
+
import { bootstrapRuntimePolicyEnforcer, type BootstrapRuntimePolicyEnforcerOptions } from './policyEnforcer'
|
|
3
|
+
import {
|
|
4
|
+
createRawTaskFromJsonRpc,
|
|
5
|
+
type JsonRpcPolicyEvent,
|
|
6
|
+
toJsonRpcResponse,
|
|
7
|
+
type JsonRpcBridgeOptions,
|
|
8
|
+
type JsonRpcRequestLike,
|
|
9
|
+
} from './frameworkAdapters'
|
|
10
|
+
import { type WireEnvelope } from './protocolTransport'
|
|
11
|
+
|
|
12
|
+
export interface AipMcpGatewayOptions {
|
|
13
|
+
sharedSecret: string
|
|
14
|
+
gatewayAgentId?: string
|
|
15
|
+
workerAgentId?: string
|
|
16
|
+
allowedMethods?: readonly string[]
|
|
17
|
+
workerCapabilityPrefix?: string
|
|
18
|
+
methodToCapability?: JsonRpcBridgeOptions['methodToCapability']
|
|
19
|
+
authorizeMethod?: JsonRpcBridgeOptions['authorizeMethod']
|
|
20
|
+
rateLimitKey?: JsonRpcBridgeOptions['rateLimitKey']
|
|
21
|
+
rateLimiter?: JsonRpcBridgeOptions['rateLimiter']
|
|
22
|
+
onAuditEvent?: (event: AipMcpGatewayAuditEvent) => void | Promise<void>
|
|
23
|
+
gatewayWireFormat?: 'json' | 'compact' | 'binary'
|
|
24
|
+
workerWireFormat?: 'json' | 'compact' | 'binary'
|
|
25
|
+
runtimePolicy?: {
|
|
26
|
+
enabled?: boolean
|
|
27
|
+
options?: BootstrapRuntimePolicyEnforcerOptions
|
|
28
|
+
concurrencyHint?: number
|
|
29
|
+
latencySensitive?: boolean
|
|
30
|
+
compatibilityFirst?: boolean
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AipMcpGatewayRuntime {
|
|
35
|
+
handleLine: (line: string) => Promise<string | null>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AipMcpGatewayAuditEvent {
|
|
39
|
+
phase: 'request_received' | 'policy' | 'verification_failed' | 'request_success' | 'request_error'
|
|
40
|
+
id?: string | number | null
|
|
41
|
+
method?: string
|
|
42
|
+
code?: number
|
|
43
|
+
message?: string
|
|
44
|
+
policy?: JsonRpcPolicyEvent
|
|
45
|
+
timestampMs: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_ALLOWED_METHODS = ['tools/call', 'resources/read', 'prompts/get']
|
|
49
|
+
|
|
50
|
+
function jsonString(value: unknown): string {
|
|
51
|
+
return JSON.stringify(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createErrorResponse(id: string | number | null, code: number, message: string): string {
|
|
55
|
+
return jsonString({
|
|
56
|
+
jsonrpc: '2.0',
|
|
57
|
+
id,
|
|
58
|
+
error: { code, message },
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseRequest(raw: string): JsonRpcRequestLike | null {
|
|
63
|
+
const parsed = JSON.parse(raw) as Partial<JsonRpcRequestLike>
|
|
64
|
+
if (parsed.jsonrpc !== '2.0') return null
|
|
65
|
+
if (typeof parsed.method !== 'string' || parsed.method.length === 0) return null
|
|
66
|
+
if (typeof parsed.id !== 'string' && typeof parsed.id !== 'number') return null
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
jsonrpc: '2.0',
|
|
70
|
+
id: parsed.id,
|
|
71
|
+
method: parsed.method,
|
|
72
|
+
params: parsed.params,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mapBridgeErrorCode(message: string): number {
|
|
77
|
+
if (message.includes('not allowed')) return -32601
|
|
78
|
+
if (message.includes('not authorized')) return -32001
|
|
79
|
+
if (message.includes('rate-limited')) return -32002
|
|
80
|
+
return -32603
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createAipMcpGatewayRuntime(options: AipMcpGatewayOptions): AipMcpGatewayRuntime {
|
|
84
|
+
const gatewayAgentId = options.gatewayAgentId ?? 'agent.gateway'
|
|
85
|
+
const workerAgentId = options.workerAgentId ?? 'agent.worker'
|
|
86
|
+
const allowedMethods = options.allowedMethods ?? DEFAULT_ALLOWED_METHODS
|
|
87
|
+
const workerCapabilityPrefix = options.workerCapabilityPrefix ?? 'mcp.'
|
|
88
|
+
const gatewayWireFormat = options.gatewayWireFormat ?? 'compact'
|
|
89
|
+
const workerWireFormat = options.workerWireFormat ?? 'compact'
|
|
90
|
+
|
|
91
|
+
async function emitAuditEvent(event: Omit<AipMcpGatewayAuditEvent, 'timestampMs'>): Promise<void> {
|
|
92
|
+
if (options.onAuditEvent) {
|
|
93
|
+
await options.onAuditEvent({
|
|
94
|
+
...event,
|
|
95
|
+
timestampMs: Date.now(),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const gateway = createAipAgentAdapter({
|
|
101
|
+
agentId: gatewayAgentId,
|
|
102
|
+
outboundSecret: options.sharedSecret,
|
|
103
|
+
sharedSecrets: { [workerAgentId]: options.sharedSecret },
|
|
104
|
+
wireFormat: gatewayWireFormat,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const worker = createAipAgentAdapter({
|
|
108
|
+
agentId: workerAgentId,
|
|
109
|
+
outboundSecret: options.sharedSecret,
|
|
110
|
+
sharedSecrets: { [gatewayAgentId]: options.sharedSecret },
|
|
111
|
+
wireFormat: workerWireFormat,
|
|
112
|
+
onTask: async (envelope) => ({
|
|
113
|
+
intent: 'RESULT',
|
|
114
|
+
content: jsonString({
|
|
115
|
+
ok: true,
|
|
116
|
+
capability: envelope.body.capability,
|
|
117
|
+
content: envelope.body.content,
|
|
118
|
+
}),
|
|
119
|
+
capability: envelope.body.capability?.startsWith(workerCapabilityPrefix)
|
|
120
|
+
? envelope.body.capability
|
|
121
|
+
: `${workerCapabilityPrefix}${envelope.body.capability ?? 'unknown'}`,
|
|
122
|
+
}),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
async handleLine(line: string): Promise<string | null> {
|
|
127
|
+
const raw = line.trim()
|
|
128
|
+
if (!raw) return null
|
|
129
|
+
|
|
130
|
+
let id: string | number | null = null
|
|
131
|
+
try {
|
|
132
|
+
const request = parseRequest(raw)
|
|
133
|
+
if (!request) {
|
|
134
|
+
await emitAuditEvent({
|
|
135
|
+
phase: 'request_error',
|
|
136
|
+
code: -32600,
|
|
137
|
+
message: 'Invalid Request',
|
|
138
|
+
})
|
|
139
|
+
return createErrorResponse(null, -32600, 'Invalid Request')
|
|
140
|
+
}
|
|
141
|
+
id = request.id
|
|
142
|
+
await emitAuditEvent({
|
|
143
|
+
phase: 'request_received',
|
|
144
|
+
id: request.id,
|
|
145
|
+
method: request.method,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const bridgeOptions: JsonRpcBridgeOptions = {
|
|
149
|
+
recipient: workerAgentId,
|
|
150
|
+
allowedMethods,
|
|
151
|
+
methodToCapability: options.methodToCapability,
|
|
152
|
+
authorizeMethod: options.authorizeMethod,
|
|
153
|
+
rateLimitKey: options.rateLimitKey,
|
|
154
|
+
rateLimiter: options.rateLimiter,
|
|
155
|
+
onPolicyEvent: async (event) => {
|
|
156
|
+
await emitAuditEvent({
|
|
157
|
+
phase: 'policy',
|
|
158
|
+
id: request.id,
|
|
159
|
+
method: request.method,
|
|
160
|
+
policy: event,
|
|
161
|
+
})
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const outbound = await createRawTaskFromJsonRpc(gateway, request, bridgeOptions)
|
|
166
|
+
let rawResponse: WireEnvelope | null = null
|
|
167
|
+
await worker.handleRaw(outbound, async (responseRaw) => {
|
|
168
|
+
rawResponse = responseRaw
|
|
169
|
+
})
|
|
170
|
+
if (!rawResponse) {
|
|
171
|
+
await emitAuditEvent({
|
|
172
|
+
phase: 'verification_failed',
|
|
173
|
+
id: request.id,
|
|
174
|
+
method: request.method,
|
|
175
|
+
code: -32603,
|
|
176
|
+
message: 'AIP worker did not produce a response',
|
|
177
|
+
})
|
|
178
|
+
return createErrorResponse(id, -32603, 'AIP worker did not produce a response')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const verified = await gateway.receiveRaw(rawResponse)
|
|
182
|
+
if (!verified.ok || !verified.received) {
|
|
183
|
+
await emitAuditEvent({
|
|
184
|
+
phase: 'verification_failed',
|
|
185
|
+
id: request.id,
|
|
186
|
+
method: request.method,
|
|
187
|
+
code: -32603,
|
|
188
|
+
message: 'AIP verification failed',
|
|
189
|
+
})
|
|
190
|
+
return createErrorResponse(id, -32603, 'AIP verification failed')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await emitAuditEvent({
|
|
194
|
+
phase: 'request_success',
|
|
195
|
+
id: request.id,
|
|
196
|
+
method: request.method,
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
return jsonString(toJsonRpcResponse(verified.received, request.id))
|
|
200
|
+
} catch (error: unknown) {
|
|
201
|
+
const message = error instanceof Error ? error.message : 'Internal error'
|
|
202
|
+
const code = mapBridgeErrorCode(message)
|
|
203
|
+
await emitAuditEvent({
|
|
204
|
+
phase: 'request_error',
|
|
205
|
+
id,
|
|
206
|
+
code,
|
|
207
|
+
message,
|
|
208
|
+
})
|
|
209
|
+
return createErrorResponse(id, code, message)
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function createAipMcpGatewayRuntimeWithPolicy(options: AipMcpGatewayOptions): Promise<AipMcpGatewayRuntime> {
|
|
216
|
+
if (!options.runtimePolicy?.enabled) {
|
|
217
|
+
return createAipMcpGatewayRuntime(options)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const { enforcer } = await bootstrapRuntimePolicyEnforcer(options.runtimePolicy.options)
|
|
221
|
+
enforcer.assertInvariant({
|
|
222
|
+
signatureVerification: true,
|
|
223
|
+
canonicalization: true,
|
|
224
|
+
replayDefense: true,
|
|
225
|
+
ttlClockSkewEnforcement: true,
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const mode = enforcer.selectMode({
|
|
229
|
+
concurrency: options.runtimePolicy.concurrencyHint ?? 1,
|
|
230
|
+
latencySensitive: options.runtimePolicy.latencySensitive,
|
|
231
|
+
compatibilityFirst: options.runtimePolicy.compatibilityFirst,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const wireFormat = mode.includes('binary') ? 'binary' : 'compact'
|
|
235
|
+
return createAipMcpGatewayRuntime({
|
|
236
|
+
...options,
|
|
237
|
+
gatewayWireFormat: wireFormat,
|
|
238
|
+
workerWireFormat: wireFormat,
|
|
239
|
+
gatewayAgentId: options.gatewayAgentId,
|
|
240
|
+
workerAgentId: options.workerAgentId,
|
|
241
|
+
onAuditEvent: async (event) => {
|
|
242
|
+
if (options.onAuditEvent) {
|
|
243
|
+
await options.onAuditEvent({
|
|
244
|
+
...event,
|
|
245
|
+
message: event.message ?? `policy-mode=${mode};wire-format=${wireFormat}`,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import http from 'node:http'
|
|
2
|
+
import type { AddressInfo } from 'node:net'
|
|
3
|
+
import { PassThrough } from 'node:stream'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { signEnvelopeHmac } from './protocol'
|
|
6
|
+
import {
|
|
7
|
+
createMcpClientCodec,
|
|
8
|
+
wrapMcpClient,
|
|
9
|
+
wrapMcpServer,
|
|
10
|
+
type JsonRpcRequest,
|
|
11
|
+
type McpSecurityOptions,
|
|
12
|
+
} from './mcpWrapper'
|
|
13
|
+
import { createHttpMcpClient, createHttpMcpHandler, createStdioMcpClient, serveMcpOverStdio } from './mcpTransports'
|
|
14
|
+
|
|
15
|
+
const SECRET = 'mcp-shared-secret'
|
|
16
|
+
|
|
17
|
+
const serverOptions: McpSecurityOptions = {
|
|
18
|
+
selfAgentId: 'agent.server',
|
|
19
|
+
sign: (envelope) => signEnvelopeHmac(envelope, SECRET, 'server-k1'),
|
|
20
|
+
receive: { secretResolver: async () => SECRET },
|
|
21
|
+
wireFormat: 'json',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const clientOptions: McpSecurityOptions = {
|
|
25
|
+
selfAgentId: 'agent.client',
|
|
26
|
+
peerAgentId: 'agent.server',
|
|
27
|
+
sign: (envelope) => signEnvelopeHmac(envelope, SECRET, 'client-k1'),
|
|
28
|
+
receive: { secretResolver: async () => SECRET },
|
|
29
|
+
wireFormat: 'json',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const echo = async (req: JsonRpcRequest) => ({ jsonrpc: '2.0' as const, id: req.id, result: { echo: req.params } })
|
|
33
|
+
|
|
34
|
+
describe('stdio transport adapter', () => {
|
|
35
|
+
const cleanups: Array<() => void> = []
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
for (const c of cleanups.splice(0)) c()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('round-trips signed MCP messages over newline-delimited streams', async () => {
|
|
41
|
+
const clientToServer = new PassThrough()
|
|
42
|
+
const serverToClient = new PassThrough()
|
|
43
|
+
|
|
44
|
+
const server = serveMcpOverStdio(wrapMcpServer(echo, serverOptions), { input: clientToServer, output: serverToClient })
|
|
45
|
+
const client = createStdioMcpClient({ input: serverToClient, output: clientToServer })
|
|
46
|
+
cleanups.push(() => server.close(), () => client.close())
|
|
47
|
+
|
|
48
|
+
const call = wrapMcpClient(client.send, clientOptions)
|
|
49
|
+
|
|
50
|
+
const first = await call({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'planner' } })
|
|
51
|
+
const second = await call({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'deploy' } })
|
|
52
|
+
|
|
53
|
+
expect(first).toEqual({ jsonrpc: '2.0', id: 1, result: { echo: { name: 'planner' } } })
|
|
54
|
+
expect(second).toEqual({ jsonrpc: '2.0', id: 2, result: { echo: { name: 'deploy' } } })
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('http transport adapter', () => {
|
|
59
|
+
let server: http.Server | undefined
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()))
|
|
62
|
+
server = undefined
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('round-trips signed MCP messages over a real HTTP server', async () => {
|
|
66
|
+
server = http.createServer(createHttpMcpHandler(wrapMcpServer(echo, serverOptions)))
|
|
67
|
+
await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', resolve))
|
|
68
|
+
const { port } = server.address() as AddressInfo
|
|
69
|
+
|
|
70
|
+
const client = createHttpMcpClient({ url: `http://127.0.0.1:${port}` })
|
|
71
|
+
const call = wrapMcpClient(client.send, clientOptions)
|
|
72
|
+
|
|
73
|
+
const response = await call({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'planner' } })
|
|
74
|
+
|
|
75
|
+
expect(response).toEqual({ jsonrpc: '2.0', id: 1, result: { echo: { name: 'planner' } } })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('rejects an unsigned/invalid request over HTTP and returns a signed JSON-RPC error', async () => {
|
|
79
|
+
server = http.createServer(createHttpMcpHandler(wrapMcpServer(echo, serverOptions)))
|
|
80
|
+
await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', resolve))
|
|
81
|
+
const { port } = server.address() as AddressInfo
|
|
82
|
+
|
|
83
|
+
const client = createHttpMcpClient({ url: `http://127.0.0.1:${port}` })
|
|
84
|
+
const codec = createMcpClientCodec(clientOptions)
|
|
85
|
+
|
|
86
|
+
// a body that is not a valid signed envelope
|
|
87
|
+
const responseRaw = await client.send('{"not":"an envelope"}')
|
|
88
|
+
const response = await codec.decodeResponse(responseRaw)
|
|
89
|
+
|
|
90
|
+
expect(response.error).toBeDefined()
|
|
91
|
+
})
|
|
92
|
+
})
|