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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "11.0.2",
3
+ "version": "11.0.4",
4
4
  "name": "snow-flow",
5
5
  "description": "Snow-Flow - ServiceNow Multi-Agent Development Framework powered by AI",
6
6
  "license": "Elastic-2.0",
@@ -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 parses the JWT from `Authorization`, looks up the tenant
15
- * instance in the portal DB, decrypts credentials via KMS, and returns a
16
- * fully-populated context with `origin: "http"`.
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
- const headers = (request as any)?.headers ?? {}
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 =