snow-flow 11.0.2 → 11.0.4
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/Dockerfile.mcp-http +2 -1
- package/bin/index.js.map +1 -1
- package/bin/worker.js.map +1 -1
- package/package.json +1 -1
- package/src/servicenow/servicenow-mcp-unified/handlers/call-tool.ts +2 -2
- package/src/servicenow/servicenow-mcp-unified/handlers/list-tools.ts +2 -2
- package/src/servicenow/servicenow-mcp-unified/handlers/types.ts +9 -4
- package/src/servicenow/servicenow-mcp-unified/transports/__tests__/http-resolver.test.ts +145 -0
- package/src/servicenow/servicenow-mcp-unified/transports/http-resolver.ts +12 -2
- package/src/servicenow/servicenow-mcp-unified/transports/stdio.ts +4 -1
package/package.json
CHANGED
|
@@ -17,7 +17,7 @@ import { mcpDebug } from "../../shared/mcp-debug.js"
|
|
|
17
17
|
import { formatArgsForLogging, isRetryableOperation } from "../shared/handler-helpers.js"
|
|
18
18
|
import { HandlerDeps } from "./types.js"
|
|
19
19
|
|
|
20
|
-
export const callTool = (deps: HandlerDeps) => async (request: any) => {
|
|
20
|
+
export const callTool = (deps: HandlerDeps) => async (request: any, extra?: any) => {
|
|
21
21
|
const { name, arguments: args } = request.params
|
|
22
22
|
|
|
23
23
|
// Enhanced logging: show tool name AND key parameters
|
|
@@ -27,7 +27,7 @@ export const callTool = (deps: HandlerDeps) => async (request: any) => {
|
|
|
27
27
|
mcpDebug(`[Server] Parameters: ${logArgs}`)
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
const ctx = await deps.resolveContext(request)
|
|
30
|
+
const ctx = await deps.resolveContext(request, extra)
|
|
31
31
|
// Fail fast if an HTTP resolver forgets to set tenantId — see list-tools.ts.
|
|
32
32
|
if (ctx.origin === "http" && !ctx.serviceNow.tenantId) {
|
|
33
33
|
throw new Error(
|
|
@@ -15,8 +15,8 @@ import { mcpDebug } from "../../shared/mcp-debug.js"
|
|
|
15
15
|
import { MCPToolDefinition } from "../shared/types.js"
|
|
16
16
|
import { HandlerDeps } from "./types.js"
|
|
17
17
|
|
|
18
|
-
export const listTools = (deps: HandlerDeps) => async (request: any) => {
|
|
19
|
-
const ctx = await deps.resolveContext(request)
|
|
18
|
+
export const listTools = (deps: HandlerDeps) => async (request: any, extra?: any) => {
|
|
19
|
+
const ctx = await deps.resolveContext(request, extra)
|
|
20
20
|
// Fail fast if an HTTP resolver forgets to set tenantId — falling back
|
|
21
21
|
// to the "stdio" sentinel under HTTP traffic would silently share
|
|
22
22
|
// ToolSessionStore state across tenants.
|
|
@@ -11,11 +11,16 @@ import { RequestContext } from "../shared/types.js"
|
|
|
11
11
|
* - Stdio transport returns a closure that produces a context with static
|
|
12
12
|
* ServiceNow credentials (loaded once at startup) plus per-request session
|
|
13
13
|
* ID derived from env vars / ToolSearch session file / JWT header.
|
|
14
|
-
* - HTTP transport
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* - HTTP transport reads the inbound `Authorization` header from
|
|
15
|
+
* `extra.requestInfo.headers` (populated by the MCP SDK's web-standard
|
|
16
|
+
* streamable HTTP transport) and forwards it to a resolver endpoint that
|
|
17
|
+
* verifies the JWT, looks up the tenant, and decrypts credentials.
|
|
18
|
+
*
|
|
19
|
+
* `extra` is the `RequestHandlerExtra` from the MCP SDK; its `requestInfo`
|
|
20
|
+
* field is the only reliable place the HTTP headers live. The JSON-RPC
|
|
21
|
+
* `request` param never carries them. stdio transports pass `undefined`.
|
|
17
22
|
*/
|
|
18
|
-
export type ContextResolver = (request: any) => Promise<RequestContext>
|
|
23
|
+
export type ContextResolver = (request: any, extra?: any) => Promise<RequestContext>
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Dependencies passed to every MCP request handler.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createHttpResolver` — contract with the MCP SDK.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's web-standard streamable HTTP transport copies HTTP headers onto
|
|
5
|
+
* `extra.requestInfo.headers`, not onto the JSON-RPC `request` object. The
|
|
6
|
+
* resolver used to read from `request.headers` and ended up forwarding an
|
|
7
|
+
* empty `authorization` to the upstream endpoint — portal then returned 400
|
|
8
|
+
* "missing authorization in body" and the client silently fell back to the
|
|
9
|
+
* direct catalog, so the HTTP transport looked "up" while actually being
|
|
10
|
+
* bypassed entirely. These tests guard against that regression.
|
|
11
|
+
*
|
|
12
|
+
* We spin up a real HTTP echo server (Bun or Node) instead of mocking `fetch`
|
|
13
|
+
* so we exercise the actual network path end-to-end.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, test, expect } from "@jest/globals"
|
|
17
|
+
import * as http from "http"
|
|
18
|
+
import { AddressInfo } from "net"
|
|
19
|
+
import { createHttpResolver } from "../http-resolver.js"
|
|
20
|
+
|
|
21
|
+
type EchoedBody = { authorization: string }
|
|
22
|
+
|
|
23
|
+
const startEchoServer = async (): Promise<{
|
|
24
|
+
url: string
|
|
25
|
+
calls: EchoedBody[]
|
|
26
|
+
internalAuthHeaders: string[]
|
|
27
|
+
stop: () => Promise<void>
|
|
28
|
+
}> => {
|
|
29
|
+
const calls: EchoedBody[] = []
|
|
30
|
+
const internalAuthHeaders: string[] = []
|
|
31
|
+
const server = http.createServer((req, res) => {
|
|
32
|
+
internalAuthHeaders.push(String(req.headers["x-internal-auth"] ?? ""))
|
|
33
|
+
const chunks: Buffer[] = []
|
|
34
|
+
req.on("data", (c) => chunks.push(Buffer.from(c)))
|
|
35
|
+
req.on("end", () => {
|
|
36
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"))
|
|
37
|
+
calls.push(body)
|
|
38
|
+
// Echo the authorization back wrapped as a minimal RequestContext so the
|
|
39
|
+
// resolver's downstream assertion (json parsing) doesn't break.
|
|
40
|
+
res.setHeader("content-type", "application/json")
|
|
41
|
+
res.end(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
origin: "http",
|
|
44
|
+
sessionId: "test",
|
|
45
|
+
jwtPayload: null,
|
|
46
|
+
serviceNow: { tenantId: "c:1", instanceUrl: "https://x", clientId: "", clientSecret: "" },
|
|
47
|
+
echoed: body.authorization,
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
|
53
|
+
const port = (server.address() as AddressInfo).port
|
|
54
|
+
return {
|
|
55
|
+
url: `http://127.0.0.1:${port}/resolve`,
|
|
56
|
+
calls,
|
|
57
|
+
internalAuthHeaders,
|
|
58
|
+
stop: () =>
|
|
59
|
+
new Promise<void>((resolve) => {
|
|
60
|
+
server.close(() => resolve())
|
|
61
|
+
}),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("createHttpResolver", () => {
|
|
66
|
+
test("forwards authorization from extra.requestInfo.headers", async () => {
|
|
67
|
+
const srv = await startEchoServer()
|
|
68
|
+
try {
|
|
69
|
+
const resolve = createHttpResolver({ url: srv.url, internalToken: "shh" })
|
|
70
|
+
const result = await resolve(
|
|
71
|
+
{},
|
|
72
|
+
{ requestInfo: { headers: { authorization: "Bearer abc.def.ghi" } } },
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
expect(srv.calls).toHaveLength(1)
|
|
76
|
+
expect(srv.calls[0]!.authorization).toBe("Bearer abc.def.ghi")
|
|
77
|
+
expect(srv.internalAuthHeaders[0]).toBe("shh")
|
|
78
|
+
expect((result as any).echoed).toBe("Bearer abc.def.ghi")
|
|
79
|
+
} finally {
|
|
80
|
+
await srv.stop()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("falls back to request.headers for legacy callers", async () => {
|
|
85
|
+
const srv = await startEchoServer()
|
|
86
|
+
try {
|
|
87
|
+
const resolve = createHttpResolver({ url: srv.url, internalToken: "shh" })
|
|
88
|
+
await resolve({ headers: { authorization: "Bearer legacy" } })
|
|
89
|
+
|
|
90
|
+
expect(srv.calls).toHaveLength(1)
|
|
91
|
+
expect(srv.calls[0]!.authorization).toBe("Bearer legacy")
|
|
92
|
+
} finally {
|
|
93
|
+
await srv.stop()
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("forwards empty string when neither source has authorization", async () => {
|
|
98
|
+
const srv = await startEchoServer()
|
|
99
|
+
try {
|
|
100
|
+
const resolve = createHttpResolver({ url: srv.url, internalToken: "shh" })
|
|
101
|
+
// The resolver forwards the empty token; it's the downstream endpoint
|
|
102
|
+
// (the portal's /api/internal/mcp-resolve) that returns 400. We only
|
|
103
|
+
// care here that we do not crash and do not silently synthesize a token.
|
|
104
|
+
await resolve({}, { requestInfo: { headers: {} } })
|
|
105
|
+
expect(srv.calls[0]!.authorization).toBe("")
|
|
106
|
+
} finally {
|
|
107
|
+
await srv.stop()
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("tolerates Authorization header with uppercase A", async () => {
|
|
112
|
+
const srv = await startEchoServer()
|
|
113
|
+
try {
|
|
114
|
+
const resolve = createHttpResolver({ url: srv.url, internalToken: "shh" })
|
|
115
|
+
// The MCP SDK lowercases, but hand-built callers (tests, alternative
|
|
116
|
+
// harnesses) may still pass the canonical-case form.
|
|
117
|
+
await resolve({}, { requestInfo: { headers: { Authorization: "Bearer upper" } } })
|
|
118
|
+
expect(srv.calls[0]!.authorization).toBe("Bearer upper")
|
|
119
|
+
} finally {
|
|
120
|
+
await srv.stop()
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test("throws when endpoint returns non-2xx", async () => {
|
|
125
|
+
// A server that always responds 400 — we expect the resolver to surface it.
|
|
126
|
+
const server = http.createServer((_req, res) => {
|
|
127
|
+
res.statusCode = 400
|
|
128
|
+
res.end("missing authorization in body")
|
|
129
|
+
})
|
|
130
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
|
131
|
+
const port = (server.address() as AddressInfo).port
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const resolve = createHttpResolver({
|
|
135
|
+
url: `http://127.0.0.1:${port}/resolve`,
|
|
136
|
+
internalToken: "shh",
|
|
137
|
+
})
|
|
138
|
+
await expect(
|
|
139
|
+
resolve({}, { requestInfo: { headers: { authorization: "Bearer x" } } }),
|
|
140
|
+
).rejects.toThrow(/mcp-resolver endpoint returned 400/)
|
|
141
|
+
} finally {
|
|
142
|
+
await new Promise<void>((resolve) => server.close(() => resolve()))
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -49,12 +49,22 @@ export interface HttpResolverOptions {
|
|
|
49
49
|
* Build a `ContextResolver` that forwards each inbound request to
|
|
50
50
|
* `opts.url`. The returned function is the exact type `createHttpApp`
|
|
51
51
|
* expects for its `resolveContext` dependency.
|
|
52
|
+
*
|
|
53
|
+
* HTTP headers live on `extra.requestInfo.headers` — the MCP SDK's
|
|
54
|
+
* web-standard streamable HTTP transport copies them off the Fetch API
|
|
55
|
+
* Request there before invoking the handler. The JSON-RPC `request`
|
|
56
|
+
* param carries only the protocol message body and never HTTP metadata,
|
|
57
|
+
* so we have to consult `extra` (and only fall back to `request.headers`
|
|
58
|
+
* to stay compatible with older call sites that pass headers inline).
|
|
52
59
|
*/
|
|
53
60
|
export const createHttpResolver = (opts: HttpResolverOptions): ContextResolver => {
|
|
54
61
|
const timeoutMs = opts.timeoutMs ?? 5000
|
|
55
62
|
|
|
56
|
-
return async (request: any) => {
|
|
57
|
-
|
|
63
|
+
return async (request: any, extra?: any) => {
|
|
64
|
+
// Headers are always lowercased by the SDK (Fetch API Headers iterator),
|
|
65
|
+
// but keep the Authorization/AUTHORIZATION fallbacks to tolerate callers
|
|
66
|
+
// that build `extra` by hand in tests.
|
|
67
|
+
const headers = extra?.requestInfo?.headers ?? (request as any)?.headers ?? {}
|
|
58
68
|
// Forward the caller's Bearer token verbatim — the resolver endpoint
|
|
59
69
|
// is the thing that knows how to verify it.
|
|
60
70
|
const authorization =
|
|
@@ -48,7 +48,10 @@ export const startStdio = async (): Promise<StdioHandle> => {
|
|
|
48
48
|
// after the server is already created.
|
|
49
49
|
let context: ServiceNowContext = loadContext()
|
|
50
50
|
|
|
51
|
-
const resolveContext = async (request: any): Promise<RequestContext> => {
|
|
51
|
+
const resolveContext = async (request: any, _extra?: any): Promise<RequestContext> => {
|
|
52
|
+
// stdio has no HTTP headers; `request.headers` is undefined here. We keep
|
|
53
|
+
// the lookup for symmetry with the HTTP resolver and because some test
|
|
54
|
+
// harnesses pass a hand-crafted request with inline headers.
|
|
52
55
|
const headers = (request as any)?.headers
|
|
53
56
|
const jwtPayload = extractJWTPayload(headers)
|
|
54
57
|
const sessionId =
|